diff --git a/QPay.postman_collection.json b/QPay.postman_collection.json new file mode 100644 index 0000000..511dcf2 --- /dev/null +++ b/QPay.postman_collection.json @@ -0,0 +1,4225 @@ +{ + "info": { + "_postman_id": "c488bb17-6d7e-4660-9e61-a065c9fb67a6", + "name": "QPay", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "569823" + }, + "item": [ + { + "name": "Integrations", + "item": [ + { + "name": "Aggregation", + "item": [ + { + "name": "Get Client Token - Omni Test", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const responseJson = pm.response.json();", + "pm.collectionVariables.set(\"accessToken\", responseJson.body.access_token);", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"clientId\": \"steward_pay\",\n \"clientSecret\": \"XjkbBAJeJCnAplYDTbp5xOjeyySVnYGp\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://api.stewardpay.co.zw/lab/v2/merchant/client/token", + "protocol": "https", + "host": [ + "api", + "stewardpay", + "co", + "zw" + ], + "path": [ + "lab", + "v2", + "merchant", + "client", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Get Client Token - Omni Live", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const responseJson = pm.response.json();", + "pm.collectionVariables.set(\"accessToken\", responseJson.body.access_token);", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"clientId\": \"qantratech_solutions\",\n \"clientSecret\": \"70f5a9d7-b5cd-4fe8-855a-b2d035a8c24c\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://api.stewardpay.co.zw/v1/merchant/client/token", + "protocol": "https", + "host": [ + "api", + "stewardpay", + "co", + "zw" + ], + "path": [ + "v1", + "merchant", + "client", + "token" + ] + } + }, + "response": [] + }, + { + "name": "Get Billers / Merchants", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "https://api.stewardpay.co.zw/v1/merchant/aggregation/providers?supportedCurrencies=USD&clientIdIn=zesa_prepaid_usd,econet_airtime,econet_usd_airtime,netone_usd_airtime", + "protocol": "https", + "host": [ + "api", + "stewardpay", + "co", + "zw" + ], + "path": [ + "v1", + "merchant", + "aggregation", + "providers" + ], + "query": [ + { + "key": "supportedCurrencies", + "value": "USD", + "type": "text" + }, + { + "key": "clientIdIn", + "value": "zesa_prepaid_usd,econet_airtime,econet_usd_airtime,netone_usd_airtime", + "type": "text" + } + ] + }, + "description": "Filterable by the following query parameters:\n\n- name: hexadecimal\n- merchantCode: {BILLER,MERCHANT}\n- merchantUid: hexadecimal uuid" + }, + "response": [ + { + "name": "Get Billers / Merchants", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:24000/v1/merchant/aggregation/providers?supportedCurrencies=USD&clientIdIn=powertel_zesa,econet_airtime,econet_broadband_usd", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "24000", + "path": [ + "v1", + "merchant", + "aggregation", + "providers" + ], + "query": [ + { + "key": "supportedCurrencies", + "value": "USD" + }, + { + "key": "clientIdIn", + "value": "powertel_zesa,econet_airtime,econet_broadband_usd" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Methods", + "value": "*" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Max-Age", + "value": "3600" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "*" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-XSS-Protection", + "value": "1; mode=block" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Date", + "value": "Thu, 12 Jun 2025 21:07:31 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"message\": \"Successful Request\",\n \"body\": {\n \"content\": [\n {\n \"clientId\": \"econet_airtime\",\n \"name\": \"ECONET AIRTIME\",\n \"description\": \"ECONET AIRTIME\",\n \"requiresAccount\": false,\n \"requiresAmount\": true,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": true,\n \"additionalDataString\": \"\",\n \"processorType\": \"AIRTIME\",\n \"uid\": \"78f1f497-c9eb-401c-b22c-884756e68e40\"\n },\n {\n \"clientId\": \"econet_broadband_usd\",\n \"name\": \"ECONET BROADBAND USD\",\n \"description\": \"ECONET BROADBAND USD\",\n \"requiresAccount\": false,\n \"requiresAmount\": false,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": false,\n \"additionalDataString\": \"\",\n \"processorType\": \"AIRTIME\",\n \"uid\": \"287863e2-a791-4106-b629-79dadc9a6779\"\n },\n {\n \"clientId\": \"powertel_zesa\",\n \"name\": \"ZESA\",\n \"description\": \"PowerTel Zesa\",\n \"requiresAccount\": true,\n \"requiresAmount\": true,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": false,\n \"processorType\": \"BILLER\",\n \"uid\": \"0f67f7cc-b62b-4fb5-915f-6740b2afdba6\"\n }\n ],\n \"pageable\": {\n \"sort\": {\n \"sorted\": true,\n \"unsorted\": false,\n \"empty\": false\n },\n \"pageNumber\": 0,\n \"pageSize\": 20,\n \"offset\": 0,\n \"unpaged\": false,\n \"paged\": true\n },\n \"totalPages\": 1,\n \"totalElements\": 3,\n \"last\": true,\n \"first\": true,\n \"sort\": {\n \"sorted\": true,\n \"unsorted\": false,\n \"empty\": false\n },\n \"numberOfElements\": 3,\n \"size\": 20,\n \"number\": 0,\n \"empty\": false\n },\n \"errors\": null,\n \"status\": 200\n}" + } + ] + }, + { + "name": "Get Products", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://192.168.16.29:24050/v1/merchant/aggregation/products?clientUid=78f1f497-c9eb-401c-b22c-884756e68e40", + "protocol": "http", + "host": [ + "192", + "168", + "16", + "29" + ], + "port": "24050", + "path": [ + "v1", + "merchant", + "aggregation", + "products" + ], + "query": [ + { + "key": "clientUid", + "value": "78f1f497-c9eb-401c-b22c-884756e68e40" + } + ] + }, + "description": "Filterable by the following query parameters:\n\n- name\n- clientUid (mandatory)" + }, + "response": [ + { + "name": "Get Products", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:24000/v1/merchant/aggregation/products?clientUid=287863e2-a791-4106-b629-79dadc9a6779", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "24000", + "path": [ + "v1", + "merchant", + "aggregation", + "products" + ], + "query": [ + { + "key": "clientUid", + "value": "287863e2-a791-4106-b629-79dadc9a6779" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Methods", + "value": "*" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Max-Age", + "value": "3600" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "*" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-XSS-Protection", + "value": "1; mode=block" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Date", + "value": "Thu, 12 Jun 2025 21:07:45 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"message\": \"Successful Request\",\n \"body\": {\n \"content\": [\n {\n \"name\": \"Bundles of Joy Voice Only 14Days USD $6.50\",\n \"description\": \"Bundles of Joy Voice Only 14Days USD $6.50\",\n \"displayName\": \"Bundles of Joy Voice Only 14Days USD\",\n \"defaultAmount\": 6.5,\n \"requiresAmount\": false,\n \"meta\": \"{\\\"pricePlanCode\\\": \\\"BOJU5\\\", \\\"currency\\\":\\\"USD\\\"}\",\n \"uid\": \"f9b7580a-66e1-49b4-bb44-e446d5ea291b\"\n },\n {\n \"name\": \"Private WiFi Bundle 10GB USD $13\",\n \"description\": \"Private WiFi Bundle 10GB USD $13\",\n \"displayName\": \"Private WiFi Bundle 10GB USD\",\n \"defaultAmount\": 13,\n \"requiresAmount\": false,\n \"meta\": \"{\\\"pricePlanCode\\\": \\\"PWBU10\\\", \\\"currency\\\":\\\"USD\\\"}\",\n \"uid\": \"478e70e5-8973-4b88-b09f-0fbf453a0fd4\"\n }\n ],\n \"pageable\": {\n \"sort\": {\n \"sorted\": false,\n \"unsorted\": true,\n \"empty\": true\n },\n \"pageNumber\": 0,\n \"pageSize\": 20,\n \"offset\": 0,\n \"unpaged\": false,\n \"paged\": true\n },\n \"totalPages\": 1,\n \"totalElements\": 2,\n \"last\": true,\n \"first\": true,\n \"sort\": {\n \"sorted\": false,\n \"unsorted\": true,\n \"empty\": true\n },\n \"numberOfElements\": 2,\n \"size\": 20,\n \"number\": 0,\n \"empty\": false\n },\n \"errors\": null,\n \"status\": 200\n}" + } + ] + }, + { + "name": "Integration Transaction - STEWARD PAY AGGREGATOR CONFIRM", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + }, + { + "key": "password", + "value": "{{Bearer Token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"type\": \"CONFIRM\",\n \"billClientId\": \"econet_airtime\", // bill being paid for\n \"debitRef\": \"Account\",\n \"debitCurrency\": \"USD\",\n \"amount\": \"3.00\",\n \"aggregatorId\": \"qantratech_solutions\", // aggregator reselling on our behalf\n \"debitPhone\": \"263773591219\",\n \"debitAccount\": \"1051184536\",\n \"creditAccount\": \"0772364284\",\n \"creditPhone\": \"0772364284\",\n \"billName\": \"ZESA\",\n \"authType\": \"WEB\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://api.stewardpay.co.zw/v1/merchant/aggregation/transaction", + "protocol": "https", + "host": [ + "api", + "stewardpay", + "co", + "zw" + ], + "path": [ + "v1", + "merchant", + "aggregation", + "transaction" + ] + } + }, + "response": [ + { + "name": "Integration Transaction", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"addressUid\": null,\n \"amount\": 55,\n \"clientUid\": \"8b5dddb4-3210-44e1-8151-6aff619bf602\",\n \"destinationPhone\": \"263773591219\",\n \"account\": \"1037601078\",\n \"phone\": \"263771222064\",\n \"integrationProcessorConfigUid\": null,\n \"paymentMethodUid\": null,\n \"paymentProcessorLabel\": \"STEWARD\",\n \"productUids\": \"84065c46-2d33-4f7f-a044-3498ce5aa533:1\",\n \"sdkActionUid\": null,\n \"type\": \"REQUEST\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:24000/v1/merchant/transaction", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "24000", + "path": [ + "v1", + "merchant", + "transaction" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Methods", + "value": "*" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Max-Age", + "value": "3600" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "*" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-XSS-Protection", + "value": "1; mode=block" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Date", + "value": "Mon, 14 Jun 2021 15:46:26 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"message\": \"Successful Request\",\n \"body\": {\n \"id\": 60,\n \"createdBy\": \"8886f80e-aaec-4628-aafc-07fda878a723\",\n \"created\": \"2021-06-14T15:46:26.472+00:00\",\n \"modifiedBy\": null,\n \"modified\": null,\n \"modificationType\": \"CREATE\",\n \"deleted\": false,\n \"uid\": \"06f9bb21-d20b-4559-9e3b-bc4d88f589bf\",\n \"amount\": 55,\n \"clientUid\": \"8b5dddb4-3210-44e1-8151-6aff619bf602\",\n \"paymentMethodUid\": null,\n \"addressUid\": null,\n \"destinationPhone\": \"263773591219\",\n \"destinationAccount\": null,\n \"phone\": \"263771222064\",\n \"account\": \"1037601078\",\n \"productUids\": \"84065c46-2d33-4f7f-a044-3498ce5aa533:1\",\n \"integrationProcessorConfigUid\": \"2e41cd89-1930-46b4-a4ef-031bf8648556\",\n \"sdkActionUid\": null,\n \"paymentProcessorConfigUid\": \"6883334e-2aa4-4a29-a12a-0ea76f5503e0\",\n \"paymentProcessorLabel\": \"STEWARD\",\n \"status\": \"SUCCESS\",\n \"errorMessage\": null,\n \"displayErrorMessage\": null,\n \"clientUpdateResult\": null,\n \"reference\": \"61f2e3f4-00df-4589-9e55-f3f3ac9d0c7f\",\n \"type\": \"INTEGRATION\",\n \"addData\": null,\n \"receiptData\": null,\n \"confirmationData\": null,\n \"userUid\": \"8886f80e-aaec-4628-aafc-07fda878a723\"\n },\n \"errors\": null,\n \"status\": 200\n}" + } + ] + }, + { + "name": "Integration Transaction - STEWARD PAY AGGREGATOR REQUEST", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + }, + { + "key": "password", + "value": "{{Bearer Token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"type\": \"REQUEST\",\n \"billClientId\": \"econet_airtime\", // bill being paid for\n \"debitRef\": \"Account\",\n \"debitCurrency\": \"USD\",\n \"amount\": \"3.00\",\n \"aggregatorId\": \"qantratech_solutions\", // aggregator reselling on our behalf\n \"debitPhone\": \"263773591219\",\n \"debitAccount\": \"1051184536\",\n \"creditAccount\": \"0772364284\",\n \"creditPhone\": \"0772364284\",\n \"billName\": \"ZESA\",\n \"authType\": \"WEB\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://api.stewardpay.co.zw/v1/merchant/aggregation/transaction", + "protocol": "https", + "host": [ + "api", + "stewardpay", + "co", + "zw" + ], + "path": [ + "v1", + "merchant", + "aggregation", + "transaction" + ] + } + }, + "response": [ + { + "name": "Integration Transaction", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"addressUid\": null,\n \"amount\": 55,\n \"clientUid\": \"8b5dddb4-3210-44e1-8151-6aff619bf602\",\n \"destinationPhone\": \"263773591219\",\n \"account\": \"1037601078\",\n \"phone\": \"263771222064\",\n \"integrationProcessorConfigUid\": null,\n \"paymentMethodUid\": null,\n \"paymentProcessorLabel\": \"STEWARD\",\n \"productUids\": \"84065c46-2d33-4f7f-a044-3498ce5aa533:1\",\n \"sdkActionUid\": null,\n \"type\": \"REQUEST\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:24000/v1/merchant/transaction", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "24000", + "path": [ + "v1", + "merchant", + "transaction" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Methods", + "value": "*" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Max-Age", + "value": "3600" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "*" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-XSS-Protection", + "value": "1; mode=block" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Date", + "value": "Mon, 14 Jun 2021 15:46:26 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"message\": \"Successful Request\",\n \"body\": {\n \"id\": 60,\n \"createdBy\": \"8886f80e-aaec-4628-aafc-07fda878a723\",\n \"created\": \"2021-06-14T15:46:26.472+00:00\",\n \"modifiedBy\": null,\n \"modified\": null,\n \"modificationType\": \"CREATE\",\n \"deleted\": false,\n \"uid\": \"06f9bb21-d20b-4559-9e3b-bc4d88f589bf\",\n \"amount\": 55,\n \"clientUid\": \"8b5dddb4-3210-44e1-8151-6aff619bf602\",\n \"paymentMethodUid\": null,\n \"addressUid\": null,\n \"destinationPhone\": \"263773591219\",\n \"destinationAccount\": null,\n \"phone\": \"263771222064\",\n \"account\": \"1037601078\",\n \"productUids\": \"84065c46-2d33-4f7f-a044-3498ce5aa533:1\",\n \"integrationProcessorConfigUid\": \"2e41cd89-1930-46b4-a4ef-031bf8648556\",\n \"sdkActionUid\": null,\n \"paymentProcessorConfigUid\": \"6883334e-2aa4-4a29-a12a-0ea76f5503e0\",\n \"paymentProcessorLabel\": \"STEWARD\",\n \"status\": \"SUCCESS\",\n \"errorMessage\": null,\n \"displayErrorMessage\": null,\n \"clientUpdateResult\": null,\n \"reference\": \"61f2e3f4-00df-4589-9e55-f3f3ac9d0c7f\",\n \"type\": \"INTEGRATION\",\n \"addData\": null,\n \"receiptData\": null,\n \"confirmationData\": null,\n \"userUid\": \"8886f80e-aaec-4628-aafc-07fda878a723\"\n },\n \"errors\": null,\n \"status\": 200\n}" + } + ] + }, + { + "name": "Integration Transaction - STEWARD AGGREGATOR", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + }, + { + "key": "password", + "value": "{{Bearer Token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"clientUid\": \"0f67f7cc-b62b-4fb5-915f-6740b2afdba6\", // bill being paid for\n \"billClientId\": \"powertel_zesa\", // bill being paid for\n \"processingCode\": \"900010\",\n \"merchantCommissionType\": \"OMNCOM\",\n \"channel\": \"OTC\",\n \"type\": \"REQUEST\",\n \"paymentProcessorLabel\": \"STEWARD_AGGREGATOR\",\n \"reference\": \"Account\",\n \"trace\": \"f98d30db-2912-4345-a290-a39ca6ad5f8b\",\n \"rrnId\": 19112.0,\n \"currency\": \"ZWG\",\n \"debitAlternateAccount\": \"1037756748\",\n \"taxType\": \"OMNIMT\",\n \"amount\": \"50.00\",\n \"charge\": 0.0,\n \"productUids\": \":1\",\n \"profile\": \"INDIVIDUAL\",\n \"accountType\": \"T24\",\n \"tax\": 0.0,\n \"aggregatorId\": \"steward_pay\", // aggregator reselling on our behalf\n \"transactionCode\": \"ACSX\",\n \"commissionType\": \"BILLPAY\",\n \"billName\": \"ZESA\",\n \"phone\": \"263771222248\",\n \"merchantTransactionCode\": \"ACCM\",\n \"merchantCommission\": 0.0,\n \"destinationAccount\": \"14309986843\",\n \"account\": \"1037756748\",\n \"billProductName\": \"\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:24000/v1/merchant/transaction", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "24000", + "path": [ + "v1", + "merchant", + "transaction" + ] + } + }, + "response": [ + { + "name": "Integration Transaction", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"addressUid\": null,\n \"amount\": 55,\n \"clientUid\": \"8b5dddb4-3210-44e1-8151-6aff619bf602\",\n \"destinationPhone\": \"263773591219\",\n \"account\": \"1037601078\",\n \"phone\": \"263771222064\",\n \"integrationProcessorConfigUid\": null,\n \"paymentMethodUid\": null,\n \"paymentProcessorLabel\": \"STEWARD\",\n \"productUids\": \"84065c46-2d33-4f7f-a044-3498ce5aa533:1\",\n \"sdkActionUid\": null,\n \"type\": \"REQUEST\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:24000/v1/merchant/transaction", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "24000", + "path": [ + "v1", + "merchant", + "transaction" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Methods", + "value": "*" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Max-Age", + "value": "3600" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "*" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-XSS-Protection", + "value": "1; mode=block" + }, + { + "key": "Cache-Control", + "value": "no-cache, no-store, max-age=0, must-revalidate" + }, + { + "key": "Pragma", + "value": "no-cache" + }, + { + "key": "Expires", + "value": "0" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Date", + "value": "Mon, 14 Jun 2021 15:46:26 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"message\": \"Successful Request\",\n \"body\": {\n \"id\": 60,\n \"createdBy\": \"8886f80e-aaec-4628-aafc-07fda878a723\",\n \"created\": \"2021-06-14T15:46:26.472+00:00\",\n \"modifiedBy\": null,\n \"modified\": null,\n \"modificationType\": \"CREATE\",\n \"deleted\": false,\n \"uid\": \"06f9bb21-d20b-4559-9e3b-bc4d88f589bf\",\n \"amount\": 55,\n \"clientUid\": \"8b5dddb4-3210-44e1-8151-6aff619bf602\",\n \"paymentMethodUid\": null,\n \"addressUid\": null,\n \"destinationPhone\": \"263773591219\",\n \"destinationAccount\": null,\n \"phone\": \"263771222064\",\n \"account\": \"1037601078\",\n \"productUids\": \"84065c46-2d33-4f7f-a044-3498ce5aa533:1\",\n \"integrationProcessorConfigUid\": \"2e41cd89-1930-46b4-a4ef-031bf8648556\",\n \"sdkActionUid\": null,\n \"paymentProcessorConfigUid\": \"6883334e-2aa4-4a29-a12a-0ea76f5503e0\",\n \"paymentProcessorLabel\": \"STEWARD\",\n \"status\": \"SUCCESS\",\n \"errorMessage\": null,\n \"displayErrorMessage\": null,\n \"clientUpdateResult\": null,\n \"reference\": \"61f2e3f4-00df-4589-9e55-f3f3ac9d0c7f\",\n \"type\": \"INTEGRATION\",\n \"addData\": null,\n \"receiptData\": null,\n \"confirmationData\": null,\n \"userUid\": \"8886f80e-aaec-4628-aafc-07fda878a723\"\n },\n \"errors\": null,\n \"status\": 200\n}" + } + ] + }, + { + "name": "Poll Transaction", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + }, + { + "key": "password", + "value": "{{Bearer Token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"clientUid\": \"0f67f7cc-b62b-4fb5-915f-6740b2afdba6\", // bill being paid for\n \"billClientId\": \"powertel_zesa\", // bill being paid for\n \"processingCode\": \"900010\",\n \"merchantCommissionType\": \"OMNCOM\",\n \"channel\": \"OTC\",\n \"type\": \"REQUEST\",\n \"paymentProcessorLabel\": \"STEWARD_AGGREGATOR\",\n \"reference\": \"Account\",\n \"trace\": \"f98d30db-2912-4345-a290-a39ca6ad5f8b\",\n \"rrnId\": 19112.0,\n \"currency\": \"ZWG\",\n \"debitAlternateAccount\": \"1037756748\",\n \"taxType\": \"OMNIMT\",\n \"amount\": \"50.00\",\n \"charge\": 0.0,\n \"productUids\": \":1\",\n \"profile\": \"INDIVIDUAL\",\n \"accountType\": \"T24\",\n \"tax\": 0.0,\n \"aggregatorId\": \"steward_pay\", // aggregator reselling on our behalf\n \"transactionCode\": \"ACSX\",\n \"commissionType\": \"BILLPAY\",\n \"billName\": \"ZESA\",\n \"phone\": \"263771222248\",\n \"merchantTransactionCode\": \"ACCM\",\n \"merchantCommission\": 0.0,\n \"destinationAccount\": \"14309986843\",\n \"account\": \"1037756748\",\n \"billProductName\": \"\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://api.stewardpay.co.zw/lab/v2/merchant/sdk/mpgs/poll/5883b8c9-e102-4544-8c06-f7321cb846a5", + "protocol": "https", + "host": [ + "api", + "stewardpay", + "co", + "zw" + ], + "path": [ + "lab", + "v2", + "merchant", + "sdk", + "mpgs", + "poll", + "5883b8c9-e102-4544-8c06-f7321cb846a5" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "SSO", + "item": [ + { + "name": "Auth", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "5d0a5d97751c1b39a0c2a002999d95c525f986b7517b717a7cbca2f597c1caf691eec1b065e1699837d6a27182ea584ccaf257adcb26d5b639f724f4416b06f6bc4656b7a3536813e75410194ae2734e382f05e6dee02bdca0631f3142a684325ffacaa7f9fe630285f2894e0ed8e7f3255e6b0d146cb40774c87adf96970343", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:1337/api/users", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "1337", + "path": [ + "api", + "users" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Fineract", + "item": [ + { + "name": "Auth", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const responseJson = pm.response.json();", + "pm.collectionVariables.set(\"accessToken\", responseJson.base64EncodedAuthenticationKey);", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"mifos\",\n \"password\": \"password\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/authentication", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "authentication" + ] + } + }, + "response": [] + }, + { + "name": "List users", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/users", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "users" + ] + } + }, + "response": [] + }, + { + "name": "List savings products", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/savingsproducts", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "savingsproducts" + ] + } + }, + "response": [] + }, + { + "name": "Create client", + "request": { + "method": "POST", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"officeId\": 1,\n \"firstname\": \"Petra\",\n \"lastname\": \"Yton\",\n \"externalId\": \"786YYH7\",\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\",\n \"active\": true,\n \"activationDate\": \"04 March 2025\",\n \"submittedOnDate\": \"04 March 2025\",\n \"savingsProductId\": 1,\n \"legalFormId\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/clients", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "clients" + ] + } + }, + "response": [] + }, + { + "name": "Retrieve client", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/clients/2", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "clients", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Create account", + "request": { + "method": "POST", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clientId\": 2,\n \"productId\": 1,\n \"locale\": \"en\",\n \"dateFormat\": \"dd M yyyy\",\n \"submittedOnDate\": \"06 08 2025\",\n \"externalId\": \"SYS-23\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/savingsaccounts", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "savingsaccounts" + ] + } + }, + "response": [] + }, + { + "name": "Approve account", + "request": { + "method": "POST", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"locale\": \"en\",\n \"dateFormat\": \"dd M yyyy\",\n \"approvedOnDate\": \"06 08 2025\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/savingsaccounts/5?command=approve", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "savingsaccounts", + "5" + ], + "query": [ + { + "key": "command", + "value": "approve" + } + ] + } + }, + "response": [] + }, + { + "name": "Activate account", + "request": { + "method": "POST", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"locale\": \"en\",\n \"dateFormat\": \"dd M yyyy\",\n \"activatedOnDate\": \"06 08 2025\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/savingsaccounts/5?command=activate", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "savingsaccounts", + "5" + ], + "query": [ + { + "key": "command", + "value": "activate" + } + ] + } + }, + "response": [] + }, + { + "name": "List client accounts", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/clients/2/accounts", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "clients", + "2", + "accounts" + ] + } + }, + "response": [] + }, + { + "name": "List accounts", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/savingsaccounts", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "savingsaccounts" + ] + } + }, + "response": [] + }, + { + "name": "Retrieve account", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/savingsaccounts/5", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "savingsaccounts", + "5" + ] + } + }, + "response": [] + }, + { + "name": "List clients", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/clients", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "clients" + ] + } + }, + "response": [] + }, + { + "name": "List Transactions", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "default", + "type": "text" + }, + { + "key": "Authorization", + "value": "Basic {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "https://173.212.247.232:8543/fineract-provider/api/v1/savingsaccounts/5?associations=all", + "protocol": "https", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8543", + "path": [ + "fineract-provider", + "api", + "v1", + "savingsaccounts", + "5" + ], + "query": [ + { + "key": "associations", + "value": "all" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "ERPNext", + "item": [ + { + "name": "POS", + "item": [ + { + "name": "Login Test", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"usr\": \"Administrator\",\n \"pwd\": \"admin\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/method/login", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "method", + "login" + ] + } + }, + "response": [] + }, + { + "name": "Check Opening Entry", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + }, + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "user", + "value": "vusumuzi@qantra.co.zw", + "type": "text" + } + ] + }, + "url": { + "raw": "http://173.212.247.232:8085/api/method/erpnext.selling.page.point_of_sale.point_of_sale.check_opening_entry", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "method", + "erpnext.selling.page.point_of_sale.point_of_sale.check_opening_entry" + ] + } + }, + "response": [] + }, + { + "name": "Create Opening Entry", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + }, + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "pos_profile", + "value": "Main", + "type": "text" + }, + { + "key": "company", + "value": "Qantra", + "type": "text" + }, + { + "key": "balance_details", + "value": "[{\"mode_of_payment\":\"Cash\",\"opening_amount\":\"0\",\"idx\":1,\"name\":\"row 1\",\"__checked\":1},{\"mode_of_payment\":\"Credit Card\",\"opening_amount\":\"0\",\"idx\":2,\"name\":\"row 2\",\"__checked\":1}]", + "type": "text" + } + ] + }, + "url": { + "raw": "http://173.212.247.232:8085/api/method/erpnext.selling.page.point_of_sale.point_of_sale.create_opening_voucher", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "method", + "erpnext.selling.page.point_of_sale.point_of_sale.create_opening_voucher" + ] + } + }, + "response": [] + }, + { + "name": "Get POS Profile", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + }, + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "pos_profile", + "value": "Main", + "type": "text" + } + ] + }, + "url": { + "raw": "http://173.212.247.232:8085/api/method/erpnext.selling.page.point_of_sale.point_of_sale.get_pos_profile_data", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "method", + "erpnext.selling.page.point_of_sale.point_of_sale.get_pos_profile_data" + ] + } + }, + "response": [] + }, + { + "name": "List POS Items", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + }, + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "start", + "value": "0", + "type": "text" + }, + { + "key": "page_length", + "value": "5", + "type": "text" + }, + { + "key": "price_list", + "value": "Standard Selling", + "type": "text" + }, + { + "key": "item_group", + "value": "All Item Groups", + "type": "text" + }, + { + "key": "search_term", + "value": "", + "type": "text" + }, + { + "key": "pos_profile", + "value": "Main", + "type": "text" + } + ] + }, + "url": { + "raw": "http://173.212.247.232:8085/api/method/erpnext.selling.page.point_of_sale.point_of_sale.get_items", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "method", + "erpnext.selling.page.point_of_sale.point_of_sale.get_items" + ] + } + }, + "response": [] + }, + { + "name": "List POS Invoices", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + }, + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/POS Invoice?fields=[\"*\"]", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "POS Invoice" + ], + "query": [ + { + "key": "fields", + "value": "[\"*\"]" + } + ] + } + }, + "response": [] + }, + { + "name": "Create POS Invoice", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + }, + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"doctype\": \"POS Invoice\",\n \"docstatus\": 1,\n \"customer\": \"Anonymous\",\n \"pos_profile\": \"Main\",\n \"is_pos\": 1,\n \"company\": \"Qantra\",\n \"posting_date\": \"2025-12-25\",\n \"due_date\": \"2025-12-25\",\n \"currency\": \"USD\",\n \"selling_price_list\": \"Standard Selling\",\n \"price_list_currency\": \"USD\",\n \"set_warehouse\": \"Stores - Q\",\n \"update_stock\": 1,\n \"total_qty\": 1,\n \"total\": 3.00,\n \"grand_total\": 3.00,\n \"paid_amount\": 3.00,\n \"change_amount\": 0,\n \"language\": \"en\",\n \"items\": [\n {\n \"item_code\": \"Abc Infants Learning Wall Map\",\n \"qty\": 1,\n \"rate\": 3.00,\n \"amount\": 3.00,\n \"doctype\": \"POS Invoice Item\"\n }\n ],\n \"sales_team\": [],\n \"payments\": [\n {\n \"mode_of_payment\": \"Cash\",\n \"amount\": 3.00,\n \"account\": \"Cash - Q\",\n \"type\": \"Cash\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/POS Invoice", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "POS Invoice" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"usr\": \"Administrator\",\n \"pwd\": \"admin\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://62.171.179.108:8080/api/method/login", + "protocol": "http", + "host": [ + "62", + "171", + "179", + "108" + ], + "port": "8080", + "path": [ + "api", + "method", + "login" + ] + } + }, + "response": [] + }, + { + "name": "Login Test", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"usr\": \"Administrator\",\n \"pwd\": \"admin\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/method/login", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "method", + "login" + ] + } + }, + "response": [] + }, + { + "name": "List Customers", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/Customer?fields=[\"name\"]&filters=[[\"name\", \"=\", \"Vusumuzi Khoza\"]]", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "Customer" + ], + "query": [ + { + "key": "fields", + "value": "[\"name\"]" + }, + { + "key": "filters", + "value": "[[\"name\", \"=\", \"Vusumuzi Khoza\"]]" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Customer", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"doctype\": \"Customer\",\n \"customer_name\": \"Gabriella Max\",\n \"customer_group\": \"Commercial\",\n \"territory\": \"All Territories\",\n \"customer_type\": \"Individual\",\n \"language\": \"en\",\n \"mobile_no\": \"263775843593\",\n \"email_id\": \"gabriellamaxx@gmail.com\",\n \"docstatus\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/Customer", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "Customer" + ] + } + }, + "response": [] + }, + { + "name": "List Sales Invoices", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/api/v2/document/Sales Invoice?fields=[\"*\"]", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v2", + "document", + "Sales Invoice" + ], + "query": [ + { + "key": "fields", + "value": "[\"*\"]" + }, + { + "key": "filters", + "value": "[[\"Sales Invoice\",\"status\",\"=\",\"Overdue\"]]", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "List Items", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + }, + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/Item?limit_start=19&fields=[\"*\"]", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "Item" + ], + "query": [ + { + "key": "filters", + "value": "[[\"Item\",\"status\",\"=\",\"Overdue\"]]", + "type": "text", + "disabled": true + }, + { + "key": "limit_page_length", + "value": "30", + "type": "text", + "disabled": true + }, + { + "key": "limit_start", + "value": "19", + "type": "text" + }, + { + "key": "fields", + "value": "[\"*\"]", + "type": "text" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Sales Invoice", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "49218fe805c9fb4", + "type": "string" + }, + { + "key": "username", + "value": "dff94c65b52ecbd", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"doctype\": \"Sales Invoice\",\n \"due_date\": \"2025-10-28\",\n \"company\": \"QantraTech\",\n \"posting_date\": \"2025-10-27\",\n \"items\": [\n {\n \"qty\": 1.0,\n \"rate\": 2.55,\n \"amount\": 2.55,\n \"item_code\": \"ECONET\"\n }\n ],\n \"docstatus\": 1.0,\n \"customer\": \"Vusumuzi Khoza\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://62.171.179.108:8080/api/v2/document/Sales Invoice", + "protocol": "http", + "host": [ + "62", + "171", + "179", + "108" + ], + "port": "8080", + "path": [ + "api", + "v2", + "document", + "Sales Invoice" + ] + } + }, + "response": [] + }, + { + "name": "Confirm Sales Invoice", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "3d7cc6371cde3ca", + "type": "string" + }, + { + "key": "username", + "value": "246f66e51aa94cf", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"docstatus\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v2/document/Sales Invoice/ACC-SINV-2025-00008", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v2", + "document", + "Sales Invoice", + "ACC-SINV-2025-00008" + ] + } + }, + "response": [] + }, + { + "name": "Create Journal Entry", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"doctype\": \"Journal Entry\",\n \"company\": \"Qantra\",\n \"accounts\": [\n {\n \"doctype\": \"Journal Entry Account\",\n \"account\": \"Bank Account - Q\",\n \"credit_in_account_currency\": 2.0\n },\n {\n \"doctype\": \"Journal Entry Account\",\n \"account\": \"Gateway Expenses - Q\",\n \"debit_in_account_currency\": 2.0,\n \"cost_center\": \"Main - Q\"\n }\n ],\n \"posting_date\": \"2025-09-27\",\n \"docstatus\": 1.0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/Journal Entry", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "Journal Entry" + ] + } + }, + "response": [] + }, + { + "name": "Create Commission Journal Entry", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"doctype\": \"Journal Entry\",\n \"company\": \"Qantra\",\n \"accounts\": [\n {\n \"doctype\": \"Journal Entry Account\",\n \"account\": \"Creditors - Q\",\n \"party\": \"Steward Bank\",\n \"debit_in_account_currency\": 4.7,\n \"cost_center\": \"Main - Q\",\n \"party_type\": \"Supplier\"\n },\n {\n \"doctype\": \"Journal Entry Account\",\n \"account\": \"Discounts Received - Q\",\n \"credit_in_account_currency\": 4.7\n }\n ],\n \"posting_date\": \"2025-09-27\",\n \"docstatus\": 1.0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/Journal Entry", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "Journal Entry" + ] + } + }, + "response": [] + }, + { + "name": "Confirm Journal Entry", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "3d7cc6371cde3ca", + "type": "string" + }, + { + "key": "username", + "value": "246f66e51aa94cf", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"docstatus\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v2/document/Journal Entry/ACC-JV-2025-00003", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v2", + "document", + "Journal Entry", + "ACC-JV-2025-00003" + ] + } + }, + "response": [] + }, + { + "name": "Create Payment Entry - Sales Invoice", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "dff94c65b52ecbd", + "type": "string" + }, + { + "key": "password", + "value": "49218fe805c9fb4", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"references\": [\n {\n \"reference_doctype\": \"Sales Invoice\",\n \"reference_name\": \"ACC-SINV-2025-00004\",\n \"allocated_amount\": 2.55\n }\n ],\n \"party_type\": \"Customer\",\n \"received_amount\": 2.55,\n \"doctype\": \"Payment Entry\",\n \"payment_type\": \"Receive\",\n \"paid_to\": \"Steward Bank Account - Q\",\n \"paid_amount\": 2.55,\n \"company\": \"QantraTech\",\n \"reference_date\": \"2025-10-27\",\n \"posting_date\": \"2025-10-27\",\n \"paid_from\": \"Debtors - Q\",\n \"party\": \"Vusumuzi Khoza\",\n \"docstatus\": 1.0,\n \"reference_no\": \"d1555ab9-6fa3-4c1d-af97-21d4f4a58a2d\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://62.171.179.108:8080/api/v2/document/Payment Entry", + "protocol": "http", + "host": [ + "62", + "171", + "179", + "108" + ], + "port": "8080", + "path": [ + "api", + "v2", + "document", + "Payment Entry" + ] + } + }, + "response": [] + }, + { + "name": "Create Purchase Invoice", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"doctype\": \"Purchase Invoice\",\n \"supplier\": \"Steward Bank\",\n \"posting_date\": \"2025-09-24\",\n \"due_date\": \"2025-09-25\",\n \"company\": \"Qantra\",\n \"is_return\": 0,\n \"currency\": \"USD\",\n \"conversion_rate\": 1,\n \"net_total\": 98.50,\n \"grand_total\": 98.50,\n \"is_paid\": 0,\n \"docstatus\": 1,\n \"items\": [\n {\n \"doctype\": \"Purchase Invoice Item\",\n \"item_code\": \"AIRTIME\",\n \"item_name\": \"AIRTIME\",\n \"qty\": 100,\n \"rate\": 1.00,\n \"amount\": 100.00,\n \"uom\": \"Unit\"\n }\n ],\n \"taxes\": [\n {\n \"doctype\": \"Purchase Taxes and Charges\",\n \"charge_type\": \"On Net Total\",\n \"account_head\": \"Discounts Received - Q\",\n \"rate\": -1.50,\n \"cost_center\": \"Main - Q\",\n \"tax_amount\": -1.5,\n \"total\": 98.5,\n \"description\": \"Discounts Received\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/Purchase Invoice", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "Purchase Invoice" + ] + } + }, + "response": [] + }, + { + "name": "Create Purchase Invoice Live", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "49218fe805c9fb4", + "type": "string" + }, + { + "key": "username", + "value": "dff94c65b52ecbd", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"net_total\": 0.94,\n \"due_date\": \"2025-12-03\",\n \"taxes\": [\n {\n \"doctype\": \"Purchase Taxes and Charges\",\n \"rate\": -6.0,\n \"total\": 0.94,\n \"description\": \"Commission\",\n \"charge_type\": \"On Net Total\",\n \"account_head\": \"Discounts Received - Q\",\n \"cost_center\": \"Main - Q\",\n \"tax_amount\": -0.06\n }\n ],\n \"doctype\": \"Purchase Invoice\",\n \"is_return\": 0.0,\n \"supplier\": \"Steward Bank\",\n \"company\": \"QantraTech\",\n \"currency\": \"USD\",\n \"grand_total\": 0.94,\n \"is_paid\": 0.0,\n \"posting_date\": \"2025-12-02\",\n \"items\": [\n {\n \"doctype\": \"Purchase Invoice Item\",\n \"qty\": 1.0,\n \"rate\": 1.0,\n \"amount\": 1.0,\n \"uom\": \"Nos\",\n \"item_code\": \"ECONET\",\n \"item_name\": \"ECONET\"\n }\n ],\n \"conversion_rate\": 1.0,\n \"docstatus\": 1.0\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://62.171.179.108:8080/api/v2/document/Purchase Invoice", + "protocol": "http", + "host": [ + "62", + "171", + "179", + "108" + ], + "port": "8080", + "path": [ + "api", + "v2", + "document", + "Purchase Invoice" + ] + } + }, + "response": [] + }, + { + "name": "Create Payment Entry - Purchase Inovice", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"doctype\": \"Payment Entry\",\n \"company\": \"Qantra\",\n \"payment_type\": \"Pay\",\n \"party_type\": \"Supplier\",\n \"party\": \"Steward Bank\",\n \"paid_from\": \"Bank Account - Q\",\n \"paid_to\": \"Creditors - Q\",\n \"paid_amount\": 100,\n \"received_amount\": 100,\n \"posting_date\": \"2025-08-27\",\n \"reference_no\": \"FT1010\",\n \"reference_date\": \"2025-08-27\",\n \"references\": [\n {\n \"reference_doctype\": \"Purchase Invoice\",\n \"reference_name\": \"ACC-PINV-2025-00017\",\n \"allocated_amount\": 100\n }\n ],\n \"docstatus\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/v2/document/Payment Entry", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "v2", + "document", + "Payment Entry" + ] + } + }, + "response": [] + }, + { + "name": "Fetch Accounts Receivable", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"report_name\": \"Accounts Receivable\",\n \"filters\": {\n \"company\": \"Qantra\",\n \"report_date\": \"2025-08-28\",\n \"party\": [\"Gabriella Max\"],\n \"ageing_based_on\": \"Due Date\",\n \"calculate_ageing_with\": \"Report Date\",\n \"range\": \"30, 60, 90, 120\",\n \"customer_group\": []\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/method/frappe.desk.query_report.run", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "method", + "frappe.desk.query_report.run" + ] + } + }, + "response": [] + }, + { + "name": "Fetch Balance", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"Accounts Receivable - Q\",\n \"date\": \"2025-09-21\",\n \"party_type\": \"Customer\",\n \"party\": \"Gabriella Max\",\n \"currency\": \"GBP\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://173.212.247.232:8085/api/method/erpnext.accounts.utils.get_balance_on", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "method", + "erpnext.accounts.utils.get_balance_on" + ] + } + }, + "response": [] + }, + { + "name": "Get Customer", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "e3d95692bcf16c7", + "type": "string" + }, + { + "key": "username", + "value": "1102c6da0312f75", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://173.212.247.232:8085/api/resource/Customer/Gabriella Max", + "protocol": "http", + "host": [ + "173", + "212", + "247", + "232" + ], + "port": "8085", + "path": [ + "api", + "resource", + "Customer", + "Gabriella Max" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Blnk", + "item": [ + { + "name": "New Transaction", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": 1000,\n \"reference\": \"first_transaction_001\",\n \"currency\": \"USD\",\n \"precision\": 100,\n \"source\": \"@FundingPool\",\n \"destination\": \"@MyBalance\",\n \"description\": \"My first Blnk transaction\",\n \"allow_overdraft\": true,\n \"meta_data\": {}\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:5001/transactions", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "transactions" + ] + } + }, + "response": [] + }, + { + "name": "Balances", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5001/balances", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "balances" + ] + } + }, + "response": [] + }, + { + "name": "Account Statement", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"q\": \"bln_0336149d-1363-4526-a05c-b0c0dbd5ff4b\",\n \"query_by\": \"source,destination\",\n \"sort_by\": \"created_at:desc\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:5001/search/transactions", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "search", + "transactions" + ] + } + }, + "response": [] + }, + { + "name": "Account Statement By Date", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"q\": \"*\",\n \"filter_by\": \"created_at:[1704067200..1764533777]\",\n \"sort_by\": \"created_at:desc\"\n }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:5001/search/transactions", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5001", + "path": [ + "search", + "transactions" + ] + } + }, + "response": [] + } + ] + } + ] + }, + { + "name": "Onboarding", + "item": [ + { + "name": "Register", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"263775843593\",\n \"email\": \"vusakhoza@gmail.com\",\n \"firstName\": \"Gabriella\",\n \"lastName\": \"Max\",\n \"phone\": \"263775843593\",\n \"password\": \"263775843593\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/auth/register", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "auth", + "register" + ] + } + }, + "response": [] + }, + { + "name": "Register Sys User", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"user@system\",\n \"email\": \"user@system\",\n \"firstName\": \"User\",\n \"lastName\": \"System\",\n \"phone\": \"263773591219\",\n \"password\": \"Password1!\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/auth/system-user", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "auth", + "system-user" + ] + } + }, + "response": [] + }, + { + "name": "Verify Otp", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"263775843593\",\n \"otpCode\": \"383095\",\n \"otpType\": \"REGISTRATION\",\n \"workflowId\": 27\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/auth/verify", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "auth", + "verify" + ] + } + }, + "response": [] + }, + { + "name": "Resend Otp", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"263773591219\",\n \"phone\": \"263773591219\",\n \"workflowId\": 2\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/auth/resend", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "auth", + "resend" + ] + } + }, + "response": [] + }, + { + "name": "Set Pin", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"263775843593\",\n \"password\": \"Lmbo_8989\",\n \"workflowId\": 4\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/auth/set-pin", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "auth", + "set-pin" + ] + } + }, + "response": [] + }, + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const responseJson = pm.response.json();", + "pm.collectionVariables.set(\"accessToken\", responseJson.token);", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"user@system\",\n \"password\": \"Password1!\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/auth/login", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "auth", + "login" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Providers", + "item": [ + { + "name": "List Categories", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/categories", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "categories" + ] + } + }, + "response": [] + }, + { + "name": "List Providers", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/providers?category=", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "providers" + ], + "query": [ + { + "key": "category", + "value": "" + } + ] + } + }, + "response": [ + { + "name": "List Providers", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/providers?category=AIRTIME", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "providers" + ], + "query": [ + { + "key": "category", + "value": "AIRTIME" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Methods", + "value": "*" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Max-Age", + "value": "3600" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "*" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Date", + "value": "Thu, 26 Jun 2025 16:13:51 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "[\n {\n \"clientId\": \"econet_airtime\",\n \"name\": \"ECONET AIRTIME\",\n \"description\": \"ECONET AIRTIME\",\n \"requiresAccount\": false,\n \"requiresAmount\": true,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": true,\n \"additionalDataString\": \"\",\n \"processorType\": \"AIRTIME\",\n \"uid\": \"78f1f497-c9eb-401c-b22c-884756e68e40\"\n },\n {\n \"clientId\": \"econet_broadband_usd\",\n \"name\": \"ECONET BROADBAND USD\",\n \"description\": \"ECONET BROADBAND USD\",\n \"requiresAccount\": false,\n \"requiresAmount\": false,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": false,\n \"additionalDataString\": \"\",\n \"processorType\": \"AIRTIME\",\n \"uid\": \"287863e2-a791-4106-b629-79dadc9a6779\"\n }\n]" + } + ] + }, + { + "name": "Evict Providers Cache", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/providers", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "providers" + ] + } + }, + "response": [ + { + "name": "List Providers", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/providers?category=AIRTIME", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "providers" + ], + "query": [ + { + "key": "category", + "value": "AIRTIME" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Methods", + "value": "*" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Max-Age", + "value": "3600" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "*" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Date", + "value": "Thu, 26 Jun 2025 16:13:51 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "[\n {\n \"clientId\": \"econet_airtime\",\n \"name\": \"ECONET AIRTIME\",\n \"description\": \"ECONET AIRTIME\",\n \"requiresAccount\": false,\n \"requiresAmount\": true,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": true,\n \"additionalDataString\": \"\",\n \"processorType\": \"AIRTIME\",\n \"uid\": \"78f1f497-c9eb-401c-b22c-884756e68e40\"\n },\n {\n \"clientId\": \"econet_broadband_usd\",\n \"name\": \"ECONET BROADBAND USD\",\n \"description\": \"ECONET BROADBAND USD\",\n \"requiresAccount\": false,\n \"requiresAmount\": false,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": false,\n \"additionalDataString\": \"\",\n \"processorType\": \"AIRTIME\",\n \"uid\": \"287863e2-a791-4106-b629-79dadc9a6779\"\n }\n]" + } + ] + }, + { + "name": "List Provider Products", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/providers/287863e2-a791-4106-b629-79dadc9a6779/products", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "providers", + "287863e2-a791-4106-b629-79dadc9a6779", + "products" + ] + } + }, + "response": [] + }, + { + "name": "Get Product", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/providers/287863e2-a791-4106-b629-79dadc9a6779/products/f9b7580a-66e1-49b4-bb44-e446d5ea291b", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "providers", + "287863e2-a791-4106-b629-79dadc9a6779", + "products", + "f9b7580a-66e1-49b4-bb44-e446d5ea291b" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Payment Processors", + "item": [ + { + "name": "List Payment Processors", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/payment-processors", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "payment-processors" + ] + } + }, + "response": [ + { + "name": "List Providers", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/providers?category=AIRTIME", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "providers" + ], + "query": [ + { + "key": "category", + "value": "AIRTIME" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Access-Control-Allow-Methods", + "value": "*" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Access-Control-Max-Age", + "value": "3600" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "*" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Transfer-Encoding", + "value": "chunked" + }, + { + "key": "Date", + "value": "Thu, 26 Jun 2025 16:13:51 GMT" + }, + { + "key": "Keep-Alive", + "value": "timeout=60" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "[\n {\n \"clientId\": \"econet_airtime\",\n \"name\": \"ECONET AIRTIME\",\n \"description\": \"ECONET AIRTIME\",\n \"requiresAccount\": false,\n \"requiresAmount\": true,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": true,\n \"additionalDataString\": \"\",\n \"processorType\": \"AIRTIME\",\n \"uid\": \"78f1f497-c9eb-401c-b22c-884756e68e40\"\n },\n {\n \"clientId\": \"econet_broadband_usd\",\n \"name\": \"ECONET BROADBAND USD\",\n \"description\": \"ECONET BROADBAND USD\",\n \"requiresAccount\": false,\n \"requiresAmount\": false,\n \"requiresAmountFromMerchant\": false,\n \"requiresPhone\": true,\n \"requiresReversal\": false,\n \"additionalDataString\": \"\",\n \"processorType\": \"AIRTIME\",\n \"uid\": \"287863e2-a791-4106-b629-79dadc9a6779\"\n }\n]" + } + ] + } + ] + }, + { + "name": "Transactions", + "item": [ + { + "name": "List Transactions", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/transaction?userId=e76678be-a69e-441e-b3ab-34f30a92c728&page=0&size=25&type=REQUEST", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "transaction" + ], + "query": [ + { + "key": "userId", + "value": "e76678be-a69e-441e-b3ab-34f30a92c728" + }, + { + "key": "page", + "value": "0" + }, + { + "key": "size", + "value": "25" + }, + { + "key": "type", + "value": "REQUEST" + } + ] + } + }, + "response": [] + }, + { + "name": "View Transaction", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/transaction/1333c2bc-212b-4bcf-926d-61023db34cb7", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "transaction", + "1333c2bc-212b-4bcf-926d-61023db34cb7" + ] + } + }, + "response": [] + }, + { + "name": "Confirmation", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": \"eb04820e-cefa-41f2-9641-57794c5f8877\",\n \"amount\": 1,\n \"paymentProcessorLabel\": \"ECOCASH\",\n \"debitPhone\": \"+2630773591219\",\n \"debitCurrency\": \"USD\",\n \"debitRef\": \"peak\",\n \"creditPhone\": \"+263773591219\",\n \"creditAccount\": \"263773591219\",\n \"billClientId\": \"econet_airtime\",\n \"billName\": \"Econet Airtime\",\n \"type\": \"CONFIRM\",\n \"authType\": \"WEB\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/public/transaction", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "transaction" + ] + } + }, + "response": [] + }, + { + "name": "Payment Request", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/transaction/request/9fd35d0c-196b-40bf-83f0-60e4e591903a/REMOTE", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "transaction", + "request", + "9fd35d0c-196b-40bf-83f0-60e4e591903a", + "REMOTE" + ] + } + }, + "response": [] + }, + { + "name": "Payment Poll", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/transaction/poll/9fd35d0c-196b-40bf-83f0-60e4e591903a", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "transaction", + "poll", + "9fd35d0c-196b-40bf-83f0-60e4e591903a" + ] + } + }, + "response": [] + }, + { + "name": "Ecocash Remote Payment", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": \"eb04820e-cefa-41f2-9641-57794c5f8877\",\n \"trace\": \"550bf98d-eeac-4a7b-8f4d-fa75cdd14b7a\",\n \"amount\": 1,\n \"paymentProcessorLabel\": \"ECOCASH\",\n \"providerLabel\": \"ECONET\",\n \"debitPhone\": \"+2630773591219\",\n \"debitAccount\": \"\",\n \"debitCurrency\": \"USD\",\n \"debitRef\": \"peak\",\n \"creditPhone\": \"+26377591219\",\n \"creditAccount\": \"077591219\",\n \"creditName\": \"\",\n \"creditEmail\": \"\",\n \"billClientId\": \"econet_airtime\",\n \"billName\": \"Econet Airtime\",\n \"paymentProcessorName\": \"Visa/MasterCard\",\n \"providerImage\": \"econet.png\",\n \"paymentProcessorImage\": \"visa-mastercard.png\",\n \"workflowId\": 0,\n \"type\": \"CONFIRM\",\n \"authType\": \"WEB\",\n \"errorMessage\": \"\",\n \"status\": \"PENDING\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/transaction", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "transaction" + ] + } + }, + "response": [] + }, + { + "name": "MPGS Web Poll", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/transaction/poll/6e95075e-3e5d-4cd1-8ab3-121157189110", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "transaction", + "poll", + "6e95075e-3e5d-4cd1-8ab3-121157189110" + ] + } + }, + "response": [] + }, + { + "name": "Integration", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/transaction/integration/9fd35d0c-196b-40bf-83f0-60e4e591903a", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "transaction", + "integration", + "9fd35d0c-196b-40bf-83f0-60e4e591903a" + ] + } + }, + "response": [] + }, + { + "name": "Steward Web Payment", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"type\": \"REQUEST\",\n \"billClientId\": \"powertel_zesa\",\n \"debitRef\": \"unique\",\n \"debitCurrency\": \"USD\",\n \"amount\": \"5\",\n \"creditAccount\": \"14309986843\",\n \"creditPhone\": \"263771222064\",\n \"billName\": \"ZESA\",\n \"paymentProcessorLabel\": \"STEWARD\",\n \"authType\": \"WEB\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/transaction", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "transaction" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Groups", + "item": [ + { + "name": "List Groups", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/recipient-groups?userId=0f86c109-001b-4ab7-a92c-a58c7fa595bb", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "recipient-groups" + ], + "query": [ + { + "key": "userId", + "value": "0f86c109-001b-4ab7-a92c-a58c7fa595bb" + } + ] + } + }, + "response": [] + }, + { + "name": "List Groups Members", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/recipient-groups/members?recipientGroupId=6e0d60ef-82d4-4e4e-9160-38927e7d705a", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "recipient-groups", + "members" + ], + "query": [ + { + "key": "recipientGroupId", + "value": "6e0d60ef-82d4-4e4e-9160-38927e7d705a" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Group", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Family Ext\",\n \"description\": \"Monthly batch\",\n \"userId\": \"0f86c109-001b-4ab7-a92c-a58c7fa595bb\",\n \"recipients\": [\n {\n \"name\": \"Alice\",\n \"phoneNumber\": \"263773591219\",\n \"latestProviderLabel\": \"ECONET\",\n \"account\": \"263773591219\"\n },\n {\n \"name\": \"Bob\",\n \"phoneNumber\": \"263775843593\",\n \"latestProviderLabel\": \"ZESA\",\n \"account\": \"07088597534\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/public/recipient-groups", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "recipient-groups" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Recipients", + "item": [ + { + "name": "List Recipients", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/recipients/search?userId=0f86c109-001b-4ab7-a92c-a58c7fa595bb", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "recipients", + "search" + ], + "query": [ + { + "key": "userId", + "value": "0f86c109-001b-4ab7-a92c-a58c7fa595bb" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Recipient", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Alice\",\n \"phoneNumber\": \"263773591219\",\n \"latestProviderLabel\": \"ECONET\",\n \"account\": \"263773591219\",\n \"userId\": \"0f86c109-001b-4ab7-a92c-a58c7fa595bb\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/public/recipients", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "recipients" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Batches", + "item": [ + { + "name": "List Batches", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/group-batches?recipientGroupId=6e0d60ef-82d4-4e4e-9160-38927e7d705a&sort=createdAt,desc", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "group-batches" + ], + "query": [ + { + "key": "recipientGroupId", + "value": "6e0d60ef-82d4-4e4e-9160-38927e7d705a" + }, + { + "key": "sort", + "value": "createdAt,desc" + } + ] + } + }, + "response": [] + }, + { + "name": "List Batches Items", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/public/group-batches/items?groupBatchId=5cd63213-f96b-4637-a8bd-d3fdf1f1df1d&sort=createdAt,desc", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "group-batches", + "items" + ], + "query": [ + { + "key": "groupBatchId", + "value": "5cd63213-f96b-4637-a8bd-d3fdf1f1df1d" + }, + { + "key": "sort", + "value": "createdAt,desc" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Batch", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const response = pm.response.json();", + "pm.collectionVariables.set(\"batchId\", response.id);" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"recipientGroupId\": \"6e0d60ef-82d4-4e4e-9160-38927e7d705a\",\n \"userId\": \"0f86c109-001b-4ab7-a92c-a58c7fa595bb\",\n \"currency\": \"USD\",\n \"amount\": 0.1,\n \"paymentProcessorLabel\": \"ECOCASH\",\n \"paymentProcessorName\": \"EcoCash\",\n \"paymentProcessorImage\": \"ecocash.png\",\n \"transactionTemplate\": {\n \"type\": \"CONFIRM\",\n \"billClientId\": \"econet_airtime\",\n \"debitName\": \"Vusa\",\n \"debitPhone\": \"263773591219\",\n \"debitRef\": \"Velocity\",\n \"debitCurrency\": \"USD\",\n \"billName\": \"Econet Airtime\",\n \"providerImage\": \"ECONET\",\n \"providerLabel\": \"ECONET\",\n \"creditName\": \"\",\n \"creditEmail\": \"\",\n \"productUid\": \"\",\n \"billProductName\": \"\",\n \"region\": \"ZW\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/public/group-batches", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "group-batches" + ] + } + }, + "response": [] + }, + { + "name": "Confirm Batch", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": \"0f86c109-001b-4ab7-a92c-a58c7fa595bb\",\n \"idempotencyKey\": \"batch-8f8b8d9e-confirm-v1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/public/group-batches/{{batchId}}/confirm", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "group-batches", + "{{batchId}}", + "confirm" + ] + } + }, + "response": [] + }, + { + "name": "Request Batch (Payment)", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": \"0f86c109-001b-4ab7-a92c-a58c7fa595bb\",\n \"idempotencyKey\": \"batch-8f8b8d9e-confirm-v4\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/public/group-batches/{{batchId}}/request", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "group-batches", + "{{batchId}}", + "request" + ] + } + }, + "response": [] + }, + { + "name": "Poll Batch", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": \"0f86c109-001b-4ab7-a92c-a58c7fa595bb\",\n \"idempotencyKey\": \"batch-8f8b8d9e-confirm-v3\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/public/group-batches/{{batchId}}/poll", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "public", + "group-batches", + "{{batchId}}", + "poll" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Seed Configs", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNjM3NzM1OTEyMTkiLCJpYXQiOjE3NTgwNTA1MjMsImV4cCI6MTc1ODEzNjkyM30.73n8v7B2BYj3OH6m68x9P6xoXlMWKd0r9ISRVIpvwTE", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/configs/seed", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "configs", + "seed" + ] + } + }, + "response": [] + }, + { + "name": "Seed Configs Live", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:6950/api/configs/seed/live", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "configs", + "seed", + "live" + ] + } + }, + "response": [] + }, + { + "name": "Check existing workflow", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"263775843593\",\n \"email\": \"gabriellamaxx@gmail.com\",\n \"firstName\": \"Gabriella\",\n \"lastName\": \"Max\",\n \"phone\": \"263775843593\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:6950/api/auth/check/263773591219-1757857033639", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "auth", + "check", + "263773591219-1757857033639" + ] + } + }, + "response": [] + }, + { + "name": "Clear Cache", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": " App0ede918b7c1110465a0518021521638c-ebc4fdb7-547d-413b-bbca-781949ac6597", + "type": "text" + } + ], + "url": { + "raw": "http://localhost:6950/api/configs/cache/clear", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "6950", + "path": [ + "api", + "configs", + "cache", + "clear" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "accessToken", + "value": "" + }, + { + "key": "batchId", + "value": "" + } + ] +} \ No newline at end of file diff --git a/group-batches.md b/group-batches.md new file mode 100644 index 0000000..c81e1b5 --- /dev/null +++ b/group-batches.md @@ -0,0 +1,83 @@ +# Plan: Groups & Batches Feature + +## API Surface (from Postman) + +### Groups +- `GET /api/public/recipient-groups?userId={userId}` — list groups +- `GET /api/public/recipient-groups/members?recipientGroupId={id}` — list members +- `POST /api/public/recipient-groups` — create group + - Body: `{ name, description, userId, recipients: [{name, phoneNumber, latestProviderLabel, account}] }` + +### Batches +- `GET /api/public/group-batches?recipientGroupId={id}&sort=createdAt,desc` — list batches +- `GET /api/public/group-batches/items?groupBatchId={id}&sort=createdAt,desc` — list batch items +- `POST /api/public/group-batches` — create batch (recipientGroupId, userId, currency, amount, paymentProcessorLabel, paymentProcessorName, paymentProcessorImage, transactionTemplate) +- `POST /api/public/group-batches/{batchId}/confirm` — confirm batch +- `POST /api/public/group-batches/{batchId}/request` — request batch (payment) +- `POST /api/public/group-batches/{batchId}/poll` — poll batch status + +## Files To Create + +### Phase 1: Models +- `lib/models/recipient_group_model.dart` — RecipientGroup, RecipientMember, CreateGroupRequest +- `lib/models/group_batch_model.dart` — GroupBatch, GroupBatchItem, CreateBatchRequest (transactionTemplate typed as TransactionModel from lib/screens/transactions/transaction_model.dart), BatchActionRequest. No separate TransactionTemplate class needed. + +### Phase 2: Controllers +- `lib/screens/groups/group_controller.dart` — ChangeNotifier, list/create groups, list members +- `lib/screens/groups/batch_controller.dart` — ChangeNotifier, list/create batches, list items, confirm/request/poll + +### Phase 3: Screens +- `lib/screens/groups/groups_screen.dart` — list all groups +- `lib/screens/groups/group_create_screen.dart` — create group form (name, description, multiline textarea: one recipient per line as `name,account`; phoneNumber defaults to account value) +- `lib/screens/groups/group_detail_screen.dart` — tabbed view: Batches first (default), Members second; receives RecipientGroup via extra; FAB on Batches tab to create new batch +- `lib/screens/groups/batch_create_screen.dart` — create batch form (provider, processor, amount, currency); on success navigate to BatchDetailScreen passing created batch via extra +- `lib/screens/groups/batch_detail_screen.dart` — batch items list + Confirm/Request/Poll action buttons +- `lib/screens/groups/batch_item_screen.dart` — single batch item detail view + +### Phase 4: Integration +- `lib/navbar.dart` — add Groups as 3rd nav item (index 2) +- `lib/main.dart` — add routes + GroupController + BatchController providers + +## Routes +- `/groups` → GroupsScreen +- `/groups/create` → GroupCreateScreen +- `/groups/:groupId` → GroupDetailScreen +- `/groups/:groupId/batches/create` → BatchCreateScreen +- `/groups/:groupId/batches/:batchId` → BatchDetailScreen +- `/groups/:groupId/batches/:batchId/items/:itemId` → BatchItemScreen + +## Action Return Types +- `createBatch` → `ApiResponse` +- `confirmBatch` → `ApiResponse` +- `requestBatch` → `ApiResponse` (reuses TransactionModel) +- `pollBatch` → `ApiResponse` +- List endpoints → `ApiResponse>` + +## BatchDetailScreen Auto-Poll Logic +After **Request** action: +1. `requestBatch()` returns `TransactionModel` +2. Check `transaction.pollingStatus` — if successful (e.g. `SUCCESS`, `COMPLETE`) → no poll needed, refresh batch +3. Otherwise → auto-trigger `pollBatch()` immediately without user input +4. If auto-poll fails (network error / non-terminal status) → show **Poll** button for manual retry +5. Batch status from poll response drives UI state (show/hide action buttons) + +## Key Patterns +- Controllers extend ChangeNotifier +- Models use `@JsonSerializable` with generated `.g.dart` files +- All network calls use `ApiResponse` wrapping pattern (try/catch in controller) +- Use `ListenableBuilder` in screens +- Read `userId`/`token` from SharedPreferences +- Pass extra data via GoRouter `extra:` parameter +- `idempotencyKey` for batch actions generated as `"batch-{batchId}-{action}-{timestamp}"` +- Navbar: add Groups at index 2, update `_handleTap` + `_calculateSelectedIndex` + +## Member Input Parsing (group_create_screen) +- One recipient per line: `name,account` +- `phoneNumber` defaults to `account` value +- `latestProviderLabel` defaults to `""` + +## Decisions +- `TransactionModel` reused as-is for `transactionTemplate` field — no wrapper class +- No pagination on groups/batches lists in this iteration +- No edit/delete for groups or batches in scope +- Poll button only shown as manual fallback after auto-poll failure diff --git a/lib/http/http.dart b/lib/http/http.dart index fd0359c..7aca082 100644 --- a/lib/http/http.dart +++ b/lib/http/http.dart @@ -43,4 +43,24 @@ class Http { logger.i(response.data.toString()); return response.data; } + + Future put(String url, dynamic data) async { + Map headers = {'Content-Type': 'application/json'}; + + Response response; + response = await dio.put( + baseUrl + url, + data: data, + options: Options(headers: headers), + ); + logger.i(response.data.toString()); + return response.data; + } + + Future delete(String url) async { + Response response; + response = await dio.delete(baseUrl + url); + logger.i(response.data.toString()); + return response.data; + } } diff --git a/lib/main.dart b/lib/main.dart index 2495961..aea4db9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,14 @@ import 'package:qpay/screens/receipt/receipt_screen.dart'; import 'package:qpay/screens/recipient/recipients_screen.dart'; import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/profile_screen.dart'; +import 'package:qpay/screens/groups/groups_screen.dart'; +import 'package:qpay/screens/groups/group_create_screen.dart'; +import 'package:qpay/screens/groups/group_detail_screen.dart'; +import 'package:qpay/screens/groups/batch_create_screen.dart'; +import 'package:qpay/screens/groups/batch_detail_screen.dart'; +import 'package:qpay/screens/groups/batch_item_screen.dart'; +import 'package:qpay/screens/groups/models/recipient_group_model.dart'; +import 'package:qpay/screens/groups/models/group_batch_model.dart'; import 'package:qpay/theme/app_theme.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -49,6 +57,7 @@ String resolveEnvFile(String env) { /// navigation to the originally intended URL. class SplashState extends ChangeNotifier { bool _complete = false; + /// The route the user originally intended to visit before being /// redirected to /splash. Set by the redirect callback on first /// invocation. @@ -188,9 +197,8 @@ class _MyAppState extends State { ), GoRoute( path: '/poll/:id', - builder: (context, state) => PollScreen( - transactionId: state.pathParameters['id'], - ), + builder: (context, state) => + PollScreen(transactionId: state.pathParameters['id']), ), GoRoute( path: '/poll', @@ -222,6 +230,43 @@ class _MyAppState extends State { path: '/profile', builder: (context, state) => const ProfileScreen(), ), + GoRoute( + path: '/groups', + builder: (context, state) => const GroupsScreen(), + ), + GoRoute( + path: '/groups/create', + builder: (context, state) => const GroupCreateScreen(), + ), + GoRoute( + path: '/groups/:groupId', + builder: (context, state) { + final group = state.extra as RecipientGroup; + return GroupDetailScreen(group: group); + }, + ), + GoRoute( + path: '/groups/:groupId/batches/create', + builder: (context, state) { + final group = state.extra as RecipientGroup; + return BatchCreateScreen(group: group); + }, + ), + GoRoute( + path: '/groups/:groupId/batches/:batchId', + builder: (context, state) { + final batchId = state.pathParameters['batchId']!; + final groupId = state.pathParameters['groupId']!; + return BatchDetailScreen(batchId: batchId, groupId: groupId); + }, + ), + GoRoute( + path: '/groups/:groupId/batches/:batchId/items/:itemId', + builder: (context, state) { + final item = state.extra as GroupBatchItem; + return BatchItemScreen(item: item); + }, + ), GoRoute( path: '/onboarding/landing', builder: (context, state) => const LandingScreen(), @@ -256,4 +301,4 @@ class _MyAppState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/navbar.dart b/lib/navbar.dart index 6718285..07d4af6 100644 --- a/lib/navbar.dart +++ b/lib/navbar.dart @@ -47,7 +47,7 @@ class _ScaffoldWithNavBarState extends State { extended: true, leading: Image( image: AssetImage('assets/velocity.png'), - width: 150 + width: 150, ), trailing: _buildLogin(context), labelType: NavigationRailLabelType.none, @@ -121,6 +121,11 @@ class _ScaffoldWithNavBarState extends State { "selectedIcon": Icon(Icons.home), "label": 'Home', }, + { + "icon": Icon(Icons.group_outlined), + "selectedIcon": Icon(Icons.group), + "label": 'Groups', + }, { "icon": Icon(Icons.history_outlined), "selectedIcon": Icon(Icons.history), @@ -130,7 +135,7 @@ class _ScaffoldWithNavBarState extends State { } Widget _buildLogin(BuildContext context) { - if(_isLoggedIn) { + if (_isLoggedIn) { return SizedBox(); } @@ -184,23 +189,19 @@ class _ScaffoldWithNavBarState extends State { case 0: context.go('/home'); break; - // case 1: - // context.go('/recipients'); - // break; case 1: + context.go('/groups'); + break; + case 2: context.go('/history'); break; - // case 2: - // context.go('/profile'); - // break; } } int _calculateSelectedIndex(BuildContext context) { final String location = GoRouterState.of(context).uri.path; - // if (location.startsWith('/recipients')) return 1; - if (location.startsWith('/history')) return 1; - // if (location.startsWith('/profile')) return 2; + if (location.startsWith('/groups')) return 1; + if (location.startsWith('/history')) return 2; return 0; } } diff --git a/lib/screens/groups/batch_create_screen.dart b/lib/screens/groups/batch_create_screen.dart new file mode 100644 index 0000000..fd77ffb --- /dev/null +++ b/lib/screens/groups/batch_create_screen.dart @@ -0,0 +1,478 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qpay/screens/groups/models/group_batch_model.dart'; +import 'package:qpay/screens/groups/models/recipient_group_model.dart'; +import 'package:qpay/models/responsive_policy.dart'; +import 'package:qpay/screens/groups/controllers/batch_controller.dart'; +import 'package:qpay/screens/home/home_controller.dart'; +import 'package:qpay/screens/pay/pay_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class BatchCreateScreen extends StatefulWidget { + final RecipientGroup group; + + const BatchCreateScreen({super.key, required this.group}); + + @override + State createState() => _BatchCreateScreenState(); +} + +class _BatchCreateScreenState extends State { + late BatchController controller; + final _formKey = GlobalKey(); + final _descriptionController = TextEditingController(); + final _amountController = TextEditingController(); + final _debitPhoneController = TextEditingController(); + String _currency = 'USD'; + bool _loadingDropdowns = true; + + @override + void initState() { + super.initState(); + controller = BatchController(context); + _init(); + } + + Future _init() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.containsKey('phone')) { + _debitPhoneController.text = prefs.getString('phone')!; + } + await Future.wait([controller.loadProviders()]); + if (mounted) setState(() => _loadingDropdowns = false); + } + + @override + void dispose() { + controller.dispose(); + _descriptionController.dispose(); + _amountController.dispose(); + _debitPhoneController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + final selectedProvider = controller.model.selectedProvider; + + if (selectedProvider == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a bill provider.')), + ); + return; + } + + final amount = double.tryParse(_amountController.text.trim()); + if (amount == null || amount <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a valid amount.')), + ); + return; + } + + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('userId') ?? ''; + + final transactionTemplate = { + 'type': 'CONFIRM', + 'billClientId': selectedProvider.clientId, + 'debitPhone': _debitPhoneController.text.trim(), + 'debitRef': 'Velocity', + 'debitCurrency': _currency, + 'billName': selectedProvider.name, + 'providerImage': selectedProvider.image, + 'providerLabel': selectedProvider.label, + 'creditName': '', + 'creditEmail': '', + 'productUid': '', + 'billProductName': '', + 'region': 'ZW', + }; + + final req = CreateBatchRequest( + recipientGroupId: widget.group.id ?? '', + userId: userId, + currency: _currency, + description: _descriptionController.text.trim(), + amount: amount, + transactionTemplate: transactionTemplate, + ); + + final result = await controller.createBatch(req); + if (!mounted) return; + + if (result.isSuccess) { + final batch = result.data!; + context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}'); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.error ?? 'Failed to create batch.')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('New Batch'), centerTitle: true), + body: ListenableBuilder( + listenable: controller, + builder: (context, _) { + if (_loadingDropdowns) { + return const Center(child: CircularProgressIndicator()); + } + return Center( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + return SizedBox( + width: width, + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionLabel('Bill Provider'), + const SizedBox(height: 8), + _buildProviderGrid(constraints), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: 'Description (optional)', + hintText: 'e.g. Monthly salaries for January', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + flex: 2, + child: TextFormField( + controller: _amountController, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: + 'Amount each recipient will receive', + hintText: 'Amount', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (v) => + (v == null || v.trim().isEmpty) + ? 'Required' + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _currency, + decoration: InputDecoration( + labelText: 'Currency', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + items: const [ + DropdownMenuItem( + value: 'USD', + child: Text('USD'), + ), + DropdownMenuItem( + value: 'ZWG', + child: Text('ZWG'), + ), + ], + onChanged: (v) { + if (v != null) + setState(() => _currency = v); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _debitPhoneController, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Your Phone Number', + hintText: '263773000000', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Phone number is required' + : null, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: controller.model.isActing + ? null + : _submit, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: controller.model.isActing + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Create Batch', + style: TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + }, + ), + ); + } + + Widget _buildSectionLabel(String text) { + return Text( + text, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ); + } + + static const Map _categoryColorMap = { + 'ECONET': Color(0xFF1A73E8), + 'NETONE': Color.fromARGB(255, 230, 127, 0), + 'TELECEL': Color(0xFF7B1FA2), + 'ZESA': Color.fromARGB(255, 230, 58, 0), + }; + + Color _resolveProviderColor(BillProvider provider) { + final exact = _categoryColorMap[provider.category.toUpperCase()]; + if (exact != null) return exact; + + final labelMatch = _categoryColorMap[provider.label.toUpperCase()]; + if (labelMatch != null) return labelMatch; + + final name = provider.name.toUpperCase(); + for (final entry in _categoryColorMap.entries) { + if (name.contains(entry.key)) return entry.value; + } + + final hash = provider.clientId.hashCode; + final hue = (hash % 360).toDouble(); + return HSVColor.fromAHSV(1.0, hue, 0.75, 0.85).toColor(); + } + + Widget _buildProviderGrid(BoxConstraints constraints) { + if (controller.model.providers.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: Colors.black26), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'No providers available', + style: TextStyle(color: Colors.grey.shade600), + ), + ); + } + + final isDark = Theme.of(context).brightness == Brightness.dark; + + return LayoutBuilder( + builder: (context, gridConstraints) { + // gridConstraints.maxWidth reflects the actual rendered width of this widget + // 3-column grid: subtract 2 gaps (10*2 = 20), then divide by 3 + final cardWidth = (gridConstraints.maxWidth - 20) / 3; + + return Wrap( + spacing: 10, + runSpacing: 10, + children: controller.model.providers + .map( + (provider) => _buildProviderCard(provider, isDark, cardWidth), + ) + .toList(), + ); + }, + ); + } + + Widget _buildProviderCard(BillProvider provider, bool isDark, double width) { + final isSelected = + controller.model.selectedProvider?.clientId == provider.clientId; + final accent = _resolveProviderColor(provider); + + return SizedBox( + width: width, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => controller.selectProvider(provider), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E1E) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? accent + : isDark + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.06), + width: isSelected ? 1.5 : 1, + ), + ), + child: Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 28, + height: 28, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Image( + image: AssetImage("assets/${provider.image}"), + errorBuilder: (_, __, ___) => Icon( + Icons.payment_rounded, + size: 14, + color: accent, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + provider.name, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + provider.description, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w400, + color: isDark ? Colors.white54 : Colors.grey.shade500, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + if (isSelected) + Positioned( + top: -2, + right: -2, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + border: Border.all( + color: isDark + ? const Color(0xFF1E1E1E) + : Colors.white, + width: 1.5, + ), + ), + child: const Icon( + Icons.check_rounded, + size: 11, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildProcessorDropdown() { + if (controller.model.processors.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: Colors.black26), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'No processors available', + style: TextStyle(color: Colors.grey.shade600), + ), + ); + } + return DropdownButtonFormField( + value: controller.model.selectedProcessor, + isExpanded: true, + decoration: InputDecoration( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + items: controller.model.processors + .map( + (p) => DropdownMenuItem( + value: p, + child: Text(p.name, overflow: TextOverflow.ellipsis), + ), + ) + .toList(), + onChanged: (p) { + if (p != null) controller.selectProcessor(p); + }, + ); + } +} diff --git a/lib/screens/groups/batch_detail_screen.dart b/lib/screens/groups/batch_detail_screen.dart new file mode 100644 index 0000000..ddb3501 --- /dev/null +++ b/lib/screens/groups/batch_detail_screen.dart @@ -0,0 +1,1353 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:qpay/screens/groups/models/group_batch_model.dart'; +import 'package:qpay/models/responsive_policy.dart'; +import 'package:qpay/screens/groups/controllers/batch_controller.dart'; +import 'package:qpay/screens/pay/pay_controller.dart'; +import 'package:qpay/widgets/status_chip_widget.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class BatchDetailScreen extends StatefulWidget { + final String batchId; + final String groupId; + + const BatchDetailScreen({ + super.key, + required this.batchId, + required this.groupId, + }); + + @override + State createState() => _BatchDetailScreenState(); +} + +class _BatchDetailScreenState extends State + with TickerProviderStateMixin { + late BatchController controller; + bool _showPollButton = false; + String _userId = ''; + int _selectedTabIndex = 0; + bool _initialLoading = true; + String? _loadError; + PaymentProcessor? _selectedProcessor; + + late AnimationController _headerAnimController; + late Animation _headerSlideAnimation; + late Animation _headerFadeAnimation; + + late AnimationController _cardAnimController; + late Animation _cardSlideAnimation; + late Animation _cardFadeAnimation; + + late AnimationController _contentAnimController; + late Animation _contentSlideAnimation; + late Animation _contentFadeAnimation; + + static const _terminalStatuses = {'SUCCESS', 'COMPLETE', 'FAILED'}; + static const _successStatuses = {'SUCCESS', 'COMPLETE'}; + + GroupBatch? get _batch => controller.model.currentBatch; + + @override + void initState() { + super.initState(); + controller = BatchController(context); + controller.loadProcessors(); + + _headerAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _headerSlideAnimation = + Tween(begin: const Offset(0, -0.08), end: Offset.zero).animate( + CurvedAnimation( + parent: _headerAnimController, + curve: Curves.easeOutCubic, + ), + ); + _headerFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _headerAnimController, curve: Curves.easeOut), + ); + + _cardAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _cardSlideAnimation = + Tween(begin: const Offset(0, 0.06), end: Offset.zero).animate( + CurvedAnimation( + parent: _cardAnimController, + curve: Curves.easeOutCubic, + ), + ); + _cardFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _cardAnimController, curve: Curves.easeOut), + ); + + _contentAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _contentSlideAnimation = + Tween(begin: const Offset(0, 0.08), end: Offset.zero).animate( + CurvedAnimation( + parent: _contentAnimController, + curve: Curves.easeOutCubic, + ), + ); + _contentFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _contentAnimController, curve: Curves.easeOut), + ); + + _headerAnimController.forward(); + Future.delayed(const Duration(milliseconds: 150), () { + if (mounted) _cardAnimController.forward(); + }); + Future.delayed(const Duration(milliseconds: 350), () { + if (mounted) _contentAnimController.forward(); + }); + + _init(); + } + + Future _init() async { + final prefs = await SharedPreferences.getInstance(); + _userId = prefs.getString('userId') ?? ''; + + final result = await controller.getBatch(widget.batchId, _userId); + if (!mounted) return; + + if (result.isSuccess) { + if (_batch?.id != null) { + await controller.listBatchItems(_batch!.id!); + } + setState(() { + _initialLoading = false; + }); + } else { + setState(() { + _initialLoading = false; + _loadError = result.error ?? 'Failed to load batch'; + }); + } + } + + @override + void dispose() { + _headerAnimController.dispose(); + _cardAnimController.dispose(); + _contentAnimController.dispose(); + controller.dispose(); + super.dispose(); + } + + Future _onConfirm() async { + final batch = _batch; + if (batch == null) return; + + final processor = _selectedProcessor; + if (processor == null) { + _showError('Please select a payment processor first.'); + return; + } + + // Persist the selected processor on the batch before confirming + final updateReq = GroupBatchUpdateRequest( + id: batch.id, + userId: _userId, + paymentProcessorLabel: processor.label, + paymentProcessorName: processor.name, + paymentProcessorImage: processor.image, + ); + final updateResult = await controller.updateBatch(updateReq); + if (!updateResult.isSuccess) { + if (mounted) { + _showError(updateResult.error ?? 'Failed to update processor.'); + } + return; + } + + final result = await controller.confirmBatch(batch.id!, _userId); + if (!mounted) return; + if (!result.isSuccess) { + _showError(result.error ?? 'Confirm failed.'); + } else { + setState(() => _selectedProcessor = null); + setState(() => _initialLoading = true); + await _init(); + } + } + + Future _onRequest() async { + final batch = _batch; + if (batch == null) return; + final result = await controller.requestBatch(batch.id!, _userId); + if (!mounted) return; + + if (!result.isSuccess) { + _showError(result.error ?? 'Request failed.'); + return; + } + + final txn = result.data!; + final pollingStatus = txn.pollingStatus?.toUpperCase(); + + if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) { + await controller.listBatchItems(batch.id!); + if (_successStatuses.contains(pollingStatus)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Payment completed successfully.')), + ); + } + } + } else { + final pollResult = await controller.pollBatch(batch.id!, _userId); + if (!mounted) return; + if (pollResult.isSuccess) { + setState(() => _showPollButton = false); + await controller.listBatchItems(batch.id!); + } else { + setState(() => _showPollButton = true); + _showError('Auto-poll failed. You can retry manually.'); + } + } + } + + Future _onPoll() async { + final batch = _batch; + if (batch == null) return; + final result = await controller.pollBatch(batch.id!, _userId); + if (!mounted) return; + if (result.isSuccess) { + setState(() => _showPollButton = false); + await controller.listBatchItems(batch.id!); + } else { + _showError(result.error ?? 'Poll failed.'); + } + } + + Future _onDownloadReport() async { + final batch = _batch; + if (batch == null) return; + final result = await controller.downloadBatchReport(batch.id!, _userId); + if (!mounted) return; + if (result.isSuccess) { + final fileUrl = result.data?['fileUrl'] as String?; + if (fileUrl != null && fileUrl.isNotEmpty) { + final uri = Uri.tryParse(fileUrl); + if (uri != null && await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + _showError('Could not open report URL.'); + } + } else { + _showError('Report URL not available.'); + } + } else { + _showError(result.error ?? 'Failed to download report.'); + } + } + + void _showError(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ────────────────────────────────────────────────────────────── + // Build + // ────────────────────────────────────────────────────────────── + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDark + ? const Color(0xFF0D0D0D) + : const Color(0xFFF8F9FA), + body: ListenableBuilder( + listenable: controller, + builder: (context, _) { + if (_initialLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_loadError != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + _loadError!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + _initialLoading = true; + _loadError = null; + }); + _init(); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final batch = _batch; + if (batch == null) { + return const Center(child: Text('Batch not found')); + } + + return _buildContent(batch, theme, isDark); + }, + ), + ); + } + + Widget _buildContent(GroupBatch batch, ThemeData theme, bool isDark) { + return CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverAppBar( + pinned: false, + floating: true, + stretch: true, + stretchTriggerOffset: 80.0, + expandedHeight: 60.0, + collapsedHeight: 60.0, + surfaceTintColor: Colors.transparent, + backgroundColor: isDark + ? const Color(0xFF0D0D0D) + : const Color(0xFFF8F9FA), + leading: context.canPop() + ? IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back_rounded), + color: theme.colorScheme.primary, + ) + : const SizedBox.shrink(), + title: Text( + batch.description ?? 'Batch', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 20, + color: isDark ? Colors.white : Colors.black87, + ), + ), + centerTitle: true, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 32), + child: LayoutBuilder( + builder: (context, constraints) { + return Center( + child: SizedBox( + width: constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth, + child: Column( + children: [ + FadeTransition( + opacity: _headerFadeAnimation, + child: SlideTransition( + position: _headerSlideAnimation, + child: _buildBatchHeader(batch, theme, isDark), + ), + ), + if (batch.status?.toUpperCase() == 'CREATED' && + controller.model.processors.isNotEmpty) ...[ + const SizedBox(height: 16), + FadeTransition( + opacity: _cardFadeAnimation, + child: SlideTransition( + position: _cardSlideAnimation, + child: _buildProcessorSelector(batch, theme), + ), + ), + ], + const SizedBox(height: 16), + FadeTransition( + opacity: _cardFadeAnimation, + child: SlideTransition( + position: _cardSlideAnimation, + child: _buildSegmentedTabBar(theme, isDark), + ), + ), + const SizedBox(height: 14), + FadeTransition( + opacity: _contentFadeAnimation, + child: SlideTransition( + position: _contentSlideAnimation, + child: _buildSelectedTabContent( + batch, + theme, + isDark, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ], + ); + } + + // ────────────────────────────────────────────────────────────── + // Status Badge + // ────────────────────────────────────────────────────────────── + Widget _buildStatusBadge(String status) { + final upper = status.toUpperCase(); + final bool isSuccess = _successStatuses.contains(upper); + final bool isProcessing = upper == 'PROCESSING'; + final bool isPending = upper == 'CREATED' || upper == 'CONFIRMED_ALL'; + + final Color bgColor = isSuccess + ? const Color(0xFFD1FAE5) + : isProcessing + ? const Color(0xFFDBEAFE) + : isPending + ? const Color(0xFFFEF3C7) + : const Color(0xFFFEE2E2); + final Color textColor = isSuccess + ? const Color(0xFF065F46) + : isProcessing + ? const Color(0xFF1E40AF) + : isPending + ? const Color(0xFF92400E) + : const Color(0xFF991B1B); + final Color dotColor = isSuccess + ? const Color(0xFF10B981) + : isProcessing + ? const Color(0xFF3B82F6) + : isPending + ? const Color(0xFFF59E0B) + : const Color(0xFFEF4444); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + status, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ], + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Dotted Divider + // ────────────────────────────────────────────────────────────── + Widget _buildDottedDivider(bool isDark) { + return Row( + children: List.generate( + 40, + (index) => Expanded( + child: Container( + height: 1.5, + color: index.isEven + ? (isDark ? Colors.white24 : Colors.grey.shade300) + : Colors.transparent, + ), + ), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Batch Header Card + // ────────────────────────────────────────────────────────────── + Widget _buildBatchHeader(GroupBatch batch, ThemeData theme, bool isDark) { + final primaryColor = theme.colorScheme.primary; + final currency = batch.currency ?? 'USD'; + final value = batch.value; + final status = batch.status ?? 'UNKNOWN'; + final processorName = batch.paymentProcessorName ?? ''; + final createdAt = batch.createdAt; + final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + primaryColor.withValues(alpha: 0.10), + primaryColor.withValues(alpha: 0.04), + primaryColor.withValues(alpha: 0.08), + ], + stops: const [0.0, 0.5, 1.0], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: primaryColor.withValues(alpha: 0.12), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: primaryColor.withValues(alpha: 0.08), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: Column( + children: [ + _buildStatusBadge(status), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + currency, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white70 : Colors.grey.shade600, + ), + ), + const SizedBox(width: 4), + Text( + value?.toStringAsFixed(2) ?? '—', + style: TextStyle( + fontSize: 42, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + height: 1.1, + ), + ), + ], + ), + if (processorName.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + processorName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: primaryColor, + ), + ), + ), + ], + const SizedBox(height: 4), + if (parsedDate != null) + Text( + DateFormat.yMMMd().add_jm().format(parsedDate), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: isDark ? Colors.white54 : Colors.grey.shade500, + ), + ), + const SizedBox(height: 16), + _buildDottedDivider(isDark), + ..._buildHeaderActionButtons(batch, theme), + ], + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Processor Selection Row + // ────────────────────────────────────────────────────────────── + Widget _buildProcessorSelector(GroupBatch batch, ThemeData theme) { + final processors = controller.model.processors; + if (processors.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Select payment processor to proceed', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 80, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: processors.length, + separatorBuilder: (_, __) => const SizedBox(width: 10), + itemBuilder: (context, index) { + final processor = processors[index]; + final isSelected = _selectedProcessor == processor; + return GestureDetector( + onTap: () { + setState(() => _selectedProcessor = processor); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + width: 140, + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : (theme.brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.06) + : Colors.white), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected + ? theme.colorScheme.primary + : (theme.brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.08) + : Colors.grey.shade200), + width: isSelected ? 2 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/${processor.image}', + width: 28, + height: 28, + errorBuilder: (_, __, ___) => Icon( + Icons.payment_rounded, + size: 28, + color: theme.colorScheme.primary, + ), + ), + ), + const SizedBox(height: 6), + Text( + processor.name, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected + ? theme.colorScheme.primary + : (theme.brightness == Brightness.dark + ? Colors.white70 + : Colors.black87), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } + + // ────────────────────────────────────────────────────────────── + // Header Action Buttons + // ────────────────────────────────────────────────────────────── + List _buildHeaderActionButtons(GroupBatch batch, ThemeData theme) { + final status = batch.status?.toUpperCase() ?? ''; + final isActing = controller.model.isActing; + + final List buttons = []; + + if (status == 'CREATED') { + buttons.add( + _modernActionButton( + icon: Icons.check_circle_outline_rounded, + label: 'Confirm', + color: _selectedProcessor != null + ? theme.colorScheme.primary + : Colors.grey, + isLoading: isActing, + onPressed: _onConfirm, + ), + ); + } else if (status == 'CONFIRMED_ALL') { + buttons.add( + _modernActionButton( + icon: Icons.send_rounded, + label: 'Request', + color: const Color(0xFF10B981), + isLoading: isActing, + onPressed: _onRequest, + ), + ); + } else if (status == 'REQUESTED' || _showPollButton) { + buttons.add( + _modernActionButton( + icon: Icons.refresh_rounded, + label: 'Check Status', + color: const Color(0xFFF59E0B), + isLoading: isActing, + onPressed: _onPoll, + ), + ); + } + + if (status == 'COMPLETED') { + buttons.add( + _modernActionButton( + icon: Icons.download_rounded, + label: 'Download Report', + color: const Color(0xFF6366F1), + isLoading: isActing, + onPressed: _onDownloadReport, + ), + ); + } + + if (buttons.isEmpty) return [const SizedBox.shrink()]; + + return [const SizedBox(height: 14), ...buttons]; + } + + Widget _modernActionButton({ + required IconData icon, + required String label, + required Color color, + required bool isLoading, + required VoidCallback onPressed, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color.withValues(alpha: 0.15), + color.withValues(alpha: 0.05), + ], + ), + border: Border.all(color: color.withValues(alpha: 0.2), width: 1.5), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: isLoading + ? Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ) + : Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(26), + onTap: onPressed, + child: Center(child: Icon(icon, color: color, size: 22)), + ), + ), + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey.shade600, + ), + ), + ], + ); + } + + // ────────────────────────────────────────────────────────────── + // Segmented Tab Bar + // ────────────────────────────────────────────────────────────── + Widget _buildSegmentedTabBar(ThemeData theme, bool isDark) { + final segments = ['Batch Details', 'Recipients']; + + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: List.generate(segments.length, (index) { + final isSelected = _selectedTabIndex == index; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selectedTabIndex = index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + boxShadow: isSelected + ? [ + BoxShadow( + color: theme.colorScheme.primary.withValues( + alpha: 0.3, + ), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: Text( + segments[index], + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : Colors.grey.shade600, + ), + ), + ), + ), + ), + ); + }), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Tab Content + // ────────────────────────────────────────────────────────────── + Widget _buildSelectedTabContent( + GroupBatch batch, + ThemeData theme, + bool isDark, + ) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.04), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: KeyedSubtree( + key: ValueKey(_selectedTabIndex), + child: _selectedTabIndex == 0 + ? _buildBatchDetailsTab(batch, theme, isDark) + : _buildRecipientsTab(batch, theme, isDark), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Batch Details Tab + // ────────────────────────────────────────────────────────────── + Widget _buildBatchDetailsTab(GroupBatch batch, ThemeData theme, bool isDark) { + final items = <_DetailItem>[ + _DetailItem( + icon: Icons.memory_rounded, + label: 'Processor', + value: batch.paymentProcessorName ?? '—', + ), + _DetailItem( + icon: Icons.group_rounded, + label: 'Group', + value: batch.recipientGroupId ?? '—', + ), + ]; + + if (batch.description != null && batch.description!.isNotEmpty) { + items.add( + _DetailItem( + icon: Icons.description_outlined, + label: 'Description', + value: batch.description!, + ), + ); + } + + if (batch.userId != null) { + items.add( + _DetailItem( + icon: Icons.person_rounded, + label: 'Created by', + value: batch.userId!, + ), + ); + } + + return Column( + children: [ + _buildInfoCard( + theme: theme, + isDark: isDark, + icon: Icons.info_outline_rounded, + title: 'Batch Details', + items: items, + ), + if (batch.count != null) ...[ + const SizedBox(height: 12), + _buildSummaryCard(batch, theme, isDark), + ], + ], + ); + } + + Widget _buildSummaryCard(GroupBatch batch, ThemeData theme, bool isDark) { + final primaryColor = theme.colorScheme.primary; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade200, + ), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.bar_chart_rounded, + size: 18, + color: primaryColor, + ), + ), + const SizedBox(width: 12), + Text( + 'Summary', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + height: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade200, + ), + const SizedBox(height: 12), + Row( + children: [ + _countBadge('Total', batch.count!, Colors.blue, isDark), + const SizedBox(width: 6), + _countBadge( + 'Successful', + batch.successfulCount ?? 0, + Colors.green, + isDark, + ), + const SizedBox(width: 6), + _countBadge('Failed', batch.failedCount ?? 0, Colors.red, isDark), + ], + ), + ], + ), + ); + } + + Widget _countBadge(String label, int value, Color color, bool isDark) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Text( + '$value', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 11, + color: color.withValues(alpha: 0.8), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Recipients Tab + // ────────────────────────────────────────────────────────────── + Widget _buildRecipientsTab(GroupBatch batch, ThemeData theme, bool isDark) { + if (controller.model.isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.model.batchItems.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text( + 'No recipients yet.', + style: TextStyle( + fontSize: 15, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + return Column( + children: controller.model.batchItems + .map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildItemCard(item, batch, theme, isDark), + ), + ) + .toList(), + ); + } + + Widget _buildItemCard( + GroupBatchItem item, + GroupBatch batch, + ThemeData theme, + bool isDark, + ) { + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade200, + ), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(10), + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: theme.colorScheme.primary.withAlpha(25), + child: Text( + item.recipientName?.isNotEmpty == true + ? item.recipientName![0].toUpperCase() + : '?', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.recipientName ?? 'Unknown', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 2), + Text( + item.recipientPhone ?? '', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${batch.currency ?? 'USD'} ${item.amount?.toStringAsFixed(2) ?? '—'}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 2), + StatusChip(status: item.status ?? 'PENDING'), + ], + ), + ], + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Info Card (shared - mirrors receipt pattern) + // ────────────────────────────────────────────────────────────── + Widget _buildInfoCard({ + required ThemeData theme, + required bool isDark, + required IconData icon, + required String title, + required List<_DetailItem> items, + }) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade200, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), + ), + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.15) + : Colors.black.withValues(alpha: 0.02), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: theme.colorScheme.primary), + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + height: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade200, + ), + const SizedBox(height: 12), + ...items.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _buildDetailRow(item, theme, isDark), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow(_DetailItem item, ThemeData theme, bool isDark) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + item.icon, + size: 16, + color: isDark ? Colors.white38 : Colors.grey.shade500, + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: Text( + item.label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white60 : Colors.grey.shade600, + ), + ), + ), + Expanded( + flex: 3, + child: Text( + item.value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + textAlign: TextAlign.end, + softWrap: true, + ), + ), + ], + ); + } +} + +// ────────────────────────────────────────────────────────────── +// Data class for detail items +// ────────────────────────────────────────────────────────────── +class _DetailItem { + final IconData icon; + final String label; + final String value; + + const _DetailItem({ + required this.icon, + required this.label, + required this.value, + }); +} diff --git a/lib/screens/groups/batch_item_screen.dart b/lib/screens/groups/batch_item_screen.dart new file mode 100644 index 0000000..4688ef6 --- /dev/null +++ b/lib/screens/groups/batch_item_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:qpay/screens/groups/models/group_batch_model.dart'; +import 'package:qpay/models/responsive_policy.dart'; +import 'package:qpay/widgets/status_chip_widget.dart'; + +class BatchItemScreen extends StatelessWidget { + final GroupBatchItem item; + + const BatchItemScreen({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(item.recipientName ?? 'Batch Item'), + centerTitle: true, + ), + body: Center( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + return SizedBox( + width: width, + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeaderCard(context), + const SizedBox(height: 16), + _buildDetailsCard(), + ], + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildHeaderCard(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.black12.withAlpha(30)), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + CircleAvatar( + radius: 32, + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withAlpha(30), + child: Text( + item.recipientName?.isNotEmpty == true + ? item.recipientName![0].toUpperCase() + : '?', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(height: 12), + Text( + item.recipientName ?? '—', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + item.recipientPhone ?? '—', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + const SizedBox(height: 12), + StatusChip(status: item.status ?? 'PENDING'), + ], + ), + ), + ); + } + + Widget _buildDetailsCard() { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.black12.withAlpha(30)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Details', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + _detailRow('Amount', _formatAmount()), + _detailRow('Status', item.status ?? '—'), + if (item.transactionId != null) + _detailRow('Transaction ID', item.transactionId!), + if (item.groupBatchId != null) + _detailRow('Batch ID', item.groupBatchId!), + if (item.createdAt != null) _detailRow('Created', item.createdAt!), + if (item.errorMessage != null && item.errorMessage!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withAlpha(60)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Error', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.red, + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Text( + item.errorMessage!, + style: const TextStyle(fontSize: 13, color: Colors.red), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + String _formatAmount() { + if (item.amount == null) return '—'; + return item.amount!.toStringAsFixed(2); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 13))), + ], + ), + ); + } +} diff --git a/lib/screens/groups/controllers/batch_controller.dart b/lib/screens/groups/controllers/batch_controller.dart new file mode 100644 index 0000000..9e58caa --- /dev/null +++ b/lib/screens/groups/controllers/batch_controller.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; +import 'package:qpay/screens/groups/models/group_batch_model.dart'; +import 'package:qpay/models/pageable_model.dart'; +import 'package:qpay/screens/home/home_controller.dart'; +import 'package:qpay/screens/pay/pay_controller.dart'; +import 'package:qpay/screens/transactions/transaction_model.dart'; + +class BatchScreenModel { + List batches = []; + List batchItems = []; + GroupBatch? currentBatch; + List providers = []; + List processors = []; + BillProvider? selectedProvider; + PaymentProcessor? selectedProcessor; + bool isLoading = false; + bool isLoadingMore = false; + bool isActing = false; + String? errorMessage; + int currentPage = 0; + int totalPages = 0; + int totalElements = 0; + String searchQuery = ''; + String? statusFilter; + String? currencyFilter; + bool selectMode = false; + Set selectedBatchIds = {}; +} + +class BatchController extends ChangeNotifier { + final BatchScreenModel model = BatchScreenModel(); + final Http http = Http(); + BuildContext context; + bool _disposed = false; + + BatchController(this.context); + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + dynamic _unwrap(dynamic response) { + if (response is Map && response.containsKey('body')) { + return response['body']; + } + return response; + } + + String buildQueryParameters(Map params) { + if (params.isEmpty) { + return ''; + } + + String query = ''; + params.forEach((key, value) { + query += '$key=$value&'; + }); + return query.substring(0, query.length - 1); + } + + Future>> loadProviders() async { + try { + final raw = await http.get( + '/public/providers?currency=USD&sort=priority,asc', + ); + final response = _unwrap(raw); + final List content; + if (response is Map && response.containsKey('content')) { + content = response['content'] as List; + } else if (response is List) { + content = response; + } else { + content = []; + } + model.providers = content + .map((e) => BillProvider.fromJson(e as Map)) + .toList(); + if (model.providers.isNotEmpty) { + model.selectedProvider = model.providers.first; + } + if (!_disposed) notifyListeners(); + return ApiResponse.success(model.providers); + } catch (e) { + logger.e(e); + return ApiResponse.failure('Failed to load providers'); + } + } + + Future>> loadProcessors() async { + try { + final raw = await http.get('/public/payment-processors'); + final response = _unwrap(raw); + final List content = response is List + ? response + : (response['content'] ?? []); + model.processors = content + .map((e) => PaymentProcessor.fromJson(e as Map)) + .toList(); + if (model.processors.isNotEmpty) { + model.selectedProcessor = model.processors.first; + } + if (!_disposed) notifyListeners(); + return ApiResponse.success(model.processors); + } catch (e) { + logger.e(e); + return ApiResponse.failure('Failed to load processors'); + } + } + + Future>> listBatches( + String groupId, { + int page = 0, + int size = 20, + String? search, + String? status, + String? currency, + }) async { + if (page == 0) { + model.isLoading = true; + model.batches = []; + model.currentPage = 0; + } else { + model.isLoadingMore = true; + } + if (!_disposed) notifyListeners(); + + try { + final params = { + 'recipientGroupId': groupId, + 'page': page.toString(), + 'size': size.toString(), + 'sort': 'createdAt,desc', + }; + if (search != null && search.isNotEmpty) { + params['description'] = search; + } + if (status != null && status.isNotEmpty) { + params['status'] = status; + } + if (currency != null && currency.isNotEmpty) { + params['currency'] = currency; + } + + final raw = await http.get( + '/public/group-batches?${buildQueryParameters(params)}', + ); + final response = _unwrap(raw); + final pageableModel = PageableModel.fromJson( + response as Map, + ); + + final newBatches = pageableModel.content + .map((e) => GroupBatch.fromJson(e as Map)) + .toList(); + + if (page == 0) { + model.batches = newBatches; + } else { + model.batches.addAll(newBatches); + } + model.currentPage = pageableModel.number; + model.totalPages = pageableModel.totalPages; + model.totalElements = pageableModel.totalElements; + model.isLoading = false; + model.isLoadingMore = false; + + if (!_disposed) notifyListeners(); + return ApiResponse.success(model.batches); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (page == 0) { + model.batches = []; + } + model.isLoading = false; + model.isLoadingMore = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to load batches'); + } + } + + Future searchBatches( + String groupId, { + String? search, + String? status, + String? currency, + }) async { + model.searchQuery = search ?? ''; + model.statusFilter = status; + model.currencyFilter = currency; + await listBatches( + groupId, + page: 0, + search: search, + status: status, + currency: currency, + ); + } + + Future loadNextPage(String groupId) async { + if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) { + return; + } + await listBatches( + groupId, + page: model.currentPage + 1, + search: model.searchQuery.isNotEmpty ? model.searchQuery : null, + status: model.statusFilter, + currency: model.currencyFilter, + ); + } + + Future>> listBatchItems( + String batchId, + ) async { + model.isLoading = true; + if (!_disposed) notifyListeners(); + try { + final raw = await http.get( + '/public/group-batches/items?groupBatchId=$batchId&sort=createdAt,desc', + ); + final response = _unwrap(raw); + final List content; + if (response is Map && response.containsKey('content')) { + content = PageableModel.fromJson( + response as Map, + ).content; + } else if (response is List) { + content = response; + } else { + content = []; + } + model.batchItems = content + .map((e) => GroupBatchItem.fromJson(e as Map)) + .toList(); + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(model.batchItems); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + model.batchItems = []; + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to load batch items'); + } + } + + Future> getBatch( + String batchId, + String userId, + ) async { + model.isActing = true; + if (!_disposed) notifyListeners(); + try { + final raw = await http.get( + '/public/group-batches/$batchId?userId=$userId', + ); + final response = _unwrap(raw); + final batch = GroupBatch.fromJson(response as Map); + model.currentBatch = batch; + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(batch); + } catch (e) { + logger.e(e); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to load batch'); + } + } + + Future> createBatch(CreateBatchRequest req) async { + model.isActing = true; + if (!_disposed) notifyListeners(); + try { + final raw = await http.post('/public/group-batches', req.toJson()); + final response = _unwrap(raw); + final batch = GroupBatch.fromJson(response as Map); + model.currentBatch = batch; + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(batch); + } catch (e, s) { + logger.e(e); + logger.e(s); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to create batch'); + } + } + + Future> updateBatch( + GroupBatchUpdateRequest request, + ) async { + model.isActing = true; + if (!_disposed) notifyListeners(); + try { + final raw = await http.put( + '/public/group-batches/${request.id}', + request.toJson(), + ); + final response = _unwrap(raw); + final batch = GroupBatch.fromJson(response as Map); + // Update the batch in the local list if present + final index = model.batches.indexWhere((b) => b.id == batch.id); + if (index != -1) { + model.batches[index] = batch; + } + model.currentBatch = batch; + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(batch); + } catch (e, s) { + logger.e(e); + logger.e(s); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to update batch'); + } + } + + Future> confirmBatch( + String batchId, + String userId, + ) async { + model.isActing = true; + if (!_disposed) notifyListeners(); + try { + final key = + 'batch-$batchId-confirm-${DateTime.now().millisecondsSinceEpoch}'; + final body = BatchActionRequest(userId: userId, idempotencyKey: key); + final raw = await http.post( + '/public/group-batches/$batchId/confirm', + body.toJson(), + ); + final response = _unwrap(raw); + final batch = GroupBatch.fromJson(response as Map); + model.currentBatch = batch; + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(batch); + } catch (e) { + logger.e(e); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to confirm batch'); + } + } + + Future> requestBatch( + String batchId, + String userId, + ) async { + model.isActing = true; + if (!_disposed) notifyListeners(); + try { + final key = + 'batch-$batchId-request-${DateTime.now().millisecondsSinceEpoch}'; + final body = BatchActionRequest(userId: userId, idempotencyKey: key); + final raw = await http.post( + '/public/group-batches/$batchId/request', + body.toJson(), + ); + final response = _unwrap(raw); + final txn = TransactionModel.fromJson(response as Map); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(txn); + } catch (e, s) { + logger.e(e); + logger.e(s); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to request batch payment'); + } + } + + Future>> downloadBatchReport( + String batchId, + String userId, + ) async { + model.isActing = true; + if (!_disposed) notifyListeners(); + try { + final raw = await http.get( + '/public/group-batches/$batchId/report?userId=$userId', + ); + final response = _unwrap(raw); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(response as Map); + } catch (e, s) { + logger.e(e); + logger.e(s); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to download batch report'); + } + } + + Future> pollBatch( + String batchId, + String userId, + ) async { + model.isActing = true; + if (!_disposed) notifyListeners(); + try { + final key = + 'batch-$batchId-poll-${DateTime.now().millisecondsSinceEpoch}'; + final body = BatchActionRequest(userId: userId, idempotencyKey: key); + final raw = await http.post( + '/public/group-batches/$batchId/poll', + body.toJson(), + ); + final response = _unwrap(raw); + final batch = GroupBatch.fromJson(response as Map); + model.currentBatch = batch; + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(batch); + } catch (e, s) { + logger.e(e); + logger.e(s); + model.isActing = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to poll batch'); + } + } + + Future deleteBatch({ + required String batchId, + required String userId, + }) async { + try { + await http.delete('/public/group-batches/$batchId?userId=$userId'); + model.batches.removeWhere((b) => b.id == batchId); + model.selectedBatchIds.remove(batchId); + model.currentBatch = null; + if (!_disposed) notifyListeners(); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (!_disposed) notifyListeners(); + } + } + + Future deleteSelectedBatches({ + required String groupId, + required String userId, + }) async { + final idsToDelete = List.from(model.selectedBatchIds); + model.selectMode = false; + model.selectedBatchIds.clear(); + if (!_disposed) notifyListeners(); + + for (final id in idsToDelete) { + await deleteBatch(batchId: id, userId: userId); + } + + // Refresh after deletion + await listBatches(groupId, page: 0); + } + + void selectProvider(BillProvider provider) { + model.selectedProvider = provider; + if (!_disposed) notifyListeners(); + } + + void selectProcessor(PaymentProcessor processor) { + model.selectedProcessor = processor; + if (!_disposed) notifyListeners(); + } + + void toggleSelectMode() { + model.selectMode = !model.selectMode; + if (!model.selectMode) { + model.selectedBatchIds.clear(); + } + if (!_disposed) notifyListeners(); + } + + void toggleBatchSelection(String batchId) { + if (model.selectedBatchIds.contains(batchId)) { + model.selectedBatchIds.remove(batchId); + } else { + model.selectedBatchIds.add(batchId); + } + if (!_disposed) notifyListeners(); + } +} diff --git a/lib/screens/groups/controllers/group_controller.dart b/lib/screens/groups/controllers/group_controller.dart new file mode 100644 index 0000000..7c85ab3 --- /dev/null +++ b/lib/screens/groups/controllers/group_controller.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; +import 'package:qpay/models/pageable_model.dart'; +import 'package:qpay/screens/groups/models/recipient_group_model.dart'; + +class GroupScreenModel { + List groups = []; + List members = []; + bool isLoading = false; + bool isLoadingMore = false; + String? errorMessage; + int currentPage = 0; + int totalPages = 0; + int totalElements = 0; + String searchQuery = ''; + bool selectMode = false; + Set selectedGroupIds = {}; +} + +class GroupController extends ChangeNotifier { + final GroupScreenModel model = GroupScreenModel(); + final Http http = Http(); + BuildContext context; + bool _disposed = false; + + GroupController(this.context); + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + dynamic _unwrap(dynamic response) { + if (response is Map && response.containsKey('body')) { + return response['body']; + } + return response; + } + + String buildQueryParameters(Map params) { + if (params.isEmpty) { + return ''; + } + + String query = ''; + params.forEach((key, value) { + query += '$key=$value&'; + }); + return query.substring(0, query.length - 1); + } + + Future> listGroups({ + required String userId, + int page = 0, + int size = 20, + String? name, + }) async { + if (page == 0) { + model.isLoading = true; + model.groups = []; + model.currentPage = 0; + } else { + model.isLoadingMore = true; + } + if (!_disposed) notifyListeners(); + + try { + final params = { + 'userId': userId, + 'page': page.toString(), + 'size': size.toString(), + }; + if (name != null && name.isNotEmpty) { + params['name'] = name; + } + + final raw = await http.get( + '/public/recipient-groups?${buildQueryParameters(params)}', + ); + final response = _unwrap(raw); + final pageableModel = + PageableModel.fromJson(response as Map); + + final newGroups = pageableModel.content + .map((e) => RecipientGroup.fromJson(e as Map)) + .toList(); + + if (page == 0) { + model.groups = newGroups; + } else { + model.groups.addAll(newGroups); + } + model.currentPage = pageableModel.number; + model.totalPages = pageableModel.totalPages; + model.totalElements = pageableModel.totalElements; + model.isLoading = false; + model.isLoadingMore = false; + + if (!_disposed) notifyListeners(); + return ApiResponse.success(pageableModel); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (page == 0) { + model.groups = []; + } + model.isLoading = false; + model.isLoadingMore = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to load groups'); + } + } + + Future searchGroups({ + required String userId, + String? name, + }) async { + model.searchQuery = name ?? ''; + await listGroups(userId: userId, page: 0, name: name); + } + + Future loadNextPage({required String userId}) async { + if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) { + return; + } + await listGroups( + userId: userId, + page: model.currentPage + 1, + name: model.searchQuery.isNotEmpty ? model.searchQuery : null, + ); + } + + Future>> listMembers( + String groupId, + ) async { + model.isLoading = true; + if (!_disposed) notifyListeners(); + try { + final raw = await http.get( + '/public/recipient-groups/members?recipientGroupId=$groupId', + ); + final response = _unwrap(raw); + final List content; + if (response is List) { + content = response; + } else if (response is Map && response.containsKey('content')) { + content = response['content'] as List; + } else { + content = []; + } + model.members = content + .map((e) => RecipientGroupMember.fromJson(e as Map)) + .toList(); + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(model.members); + } catch (e, s) { + logger.e(e); + logger.e(s); + model.errorMessage = e.toString(); + model.members = []; + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to load members'); + } + } + + Future> createGroup( + CreateGroupRequest req, + ) async { + model.isLoading = true; + if (!_disposed) notifyListeners(); + try { + final raw = await http.post('/public/recipient-groups', req.toJson()); + final response = _unwrap(raw); + final group = RecipientGroup.fromJson(response as Map); + model.groups = [group, ...model.groups]; + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(group); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + model.isLoading = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to create group'); + } + } + + Future deleteGroup({ + required String groupId, + required String userId, + }) async { + try { + await http.delete( + '/public/recipient-groups/delete/$groupId?userId=$userId', + ); + model.groups.removeWhere((g) => g.id == groupId); + model.selectedGroupIds.remove(groupId); + if (!_disposed) notifyListeners(); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (!_disposed) notifyListeners(); + } + } + + void toggleSelectMode() { + model.selectMode = !model.selectMode; + if (!model.selectMode) { + model.selectedGroupIds.clear(); + } + if (!_disposed) notifyListeners(); + } + + void toggleGroupSelection(String groupId) { + if (model.selectedGroupIds.contains(groupId)) { + model.selectedGroupIds.remove(groupId); + } else { + model.selectedGroupIds.add(groupId); + } + if (!_disposed) notifyListeners(); + } + + Future removeMember({ + required String groupId, + required String recipientId, + required String userId, + }) async { + try { + await http.delete( + '/public/recipient-groups/$groupId/members/$recipientId?userId=$userId', + ); + model.members.removeWhere((m) => m.id == recipientId); + if (!_disposed) notifyListeners(); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (!_disposed) notifyListeners(); + } + } + + Future deleteSelectedGroups({required String userId}) async { + final idsToDelete = List.from(model.selectedGroupIds); + model.selectMode = false; + model.selectedGroupIds.clear(); + if (!_disposed) notifyListeners(); + + for (final id in idsToDelete) { + await deleteGroup(groupId: id, userId: userId); + } + } +} \ No newline at end of file diff --git a/lib/screens/groups/group_create_screen.dart b/lib/screens/groups/group_create_screen.dart new file mode 100644 index 0000000..171511e --- /dev/null +++ b/lib/screens/groups/group_create_screen.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qpay/screens/groups/models/recipient_group_model.dart'; +import 'package:qpay/models/responsive_policy.dart'; +import 'package:qpay/screens/groups/controllers/group_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../recipient/recipients_controller.dart'; + +class GroupCreateScreen extends StatefulWidget { + const GroupCreateScreen({super.key}); + + @override + State createState() => _GroupCreateScreenState(); +} + +class _GroupCreateScreenState extends State { + late GroupController controller; + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _recipientsController = TextEditingController(); + + @override + void initState() { + super.initState(); + controller = GroupController(context); + } + + @override + void dispose() { + controller.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + _recipientsController.dispose(); + super.dispose(); + } + + List _parseRecipients(String raw) { + final lines = raw + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + final List members = []; + for (final line in lines) { + final parts = line.split(','); + if (parts.length >= 2) { + final name = parts[0].trim(); + final account = parts[1].trim(); + if (name.isNotEmpty && account.isNotEmpty) { + members.add( + Recipient( + name: name, + account: account, + phoneNumber: account, + latestProviderLabel: '', + ), + ); + } + } + } + return members; + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + final recipients = _parseRecipients(_recipientsController.text); + if (recipients.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Please add at least one recipient in name,account format.', + ), + ), + ); + return; + } + + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('userId') ?? ''; + + final req = CreateGroupRequest( + name: _nameController.text.trim(), + description: _descriptionController.text.trim().isEmpty + ? null + : _descriptionController.text.trim(), + userId: userId, + recipients: recipients, + ); + + final result = await controller.createGroup(req); + if (!mounted) return; + + if (result.isSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Group created successfully.')), + ); + context.pop(true); // Return true to indicate a new group was created + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.error ?? 'Failed to create group.')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('New Group'), centerTitle: true), + body: ListenableBuilder( + listenable: controller, + builder: (context, _) { + return Center( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + return SizedBox( + width: width, + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _nameController, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: 'Group Name', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Group name is required' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Recipients', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 4), + Text( + 'Enter one recipient per line as: name,account', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _recipientsController, + maxLines: 8, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: + 'Alice,263773000001\nBob,263773000002\nCarol,263773000003', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: controller.model.isLoading + ? null + : _submit, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: controller.model.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Create Group', + style: TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/groups/group_detail_screen.dart b/lib/screens/groups/group_detail_screen.dart new file mode 100644 index 0000000..67adf6d --- /dev/null +++ b/lib/screens/groups/group_detail_screen.dart @@ -0,0 +1,808 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qpay/screens/groups/models/group_batch_model.dart'; +import 'package:qpay/screens/groups/models/recipient_group_model.dart'; +import 'package:qpay/models/responsive_policy.dart'; +import 'package:qpay/screens/groups/controllers/batch_controller.dart'; +import 'package:qpay/screens/groups/controllers/group_controller.dart'; +import 'package:qpay/widgets/status_chip_widget.dart'; + +class GroupDetailScreen extends StatefulWidget { + final RecipientGroup group; + + const GroupDetailScreen({super.key, required this.group}); + + @override + State createState() => _GroupDetailScreenState(); +} + +class _GroupDetailScreenState extends State { + late GroupController groupController; + late BatchController batchController; + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + String? _statusFilter; + String? _currencyFilter; + + static const _statusFilterItems = >[ + DropdownMenuItem(value: null, child: Text('All')), + DropdownMenuItem(value: 'CREATED', child: Text('Created')), + DropdownMenuItem(value: 'PENDING', child: Text('Pending')), + DropdownMenuItem(value: 'CONFIRMED_ALL', child: Text('Confirmed')), + DropdownMenuItem(value: 'REQUESTED', child: Text('Requested')), + DropdownMenuItem(value: 'PROCESSING', child: Text('Processing')), + DropdownMenuItem(value: 'COMPLETED', child: Text('Completed')), + DropdownMenuItem(value: 'FAILED', child: Text('Failed')), + ]; + + static const _currencyFilterItems = >[ + DropdownMenuItem(value: null, child: Text('All')), + DropdownMenuItem(value: 'USD', child: Text('USD')), + DropdownMenuItem(value: 'ZWL', child: Text('ZWL')), + DropdownMenuItem(value: 'ZAR', child: Text('ZAR')), + ]; + + @override + void initState() { + super.initState(); + groupController = GroupController(context); + batchController = BatchController(context); + _loadData(); + _scrollController.addListener(_onScroll); + _searchController.addListener(() => setState(() {})); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + batchController.loadNextPage(widget.group.id ?? ''); + } + } + + Future _loadData() async { + final groupId = widget.group.id ?? ''; + await Future.wait([ + batchController.listBatches(groupId), + groupController.listMembers(groupId), + ]); + } + + void _onSearchChanged(String value) { + batchController.searchBatches( + widget.group.id ?? '', + search: value.isNotEmpty ? value : null, + status: _statusFilter, + currency: _currencyFilter, + ); + } + + void _showMembersSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.85, + expand: false, + builder: (context, scrollController) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Members', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${groupController.model.members.length} member(s)', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + const SizedBox(height: 12), + const Divider(), + Expanded( + child: ListenableBuilder( + listenable: groupController, + builder: (context, _) { + if (groupController.model.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (groupController.model.members.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.person_outline, + size: 48, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + const Text( + 'No members found.', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } + + return ListView.separated( + controller: scrollController, + itemCount: groupController.model.members.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final member = groupController.model.members[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + vertical: 2, + horizontal: 4, + ), + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withAlpha(30), + child: Text( + member.recipient.name?.isNotEmpty == true + ? member.recipient.name![0].toUpperCase() + : '?', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + title: Text( + member.recipient.name ?? '', + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text(member.recipient.account ?? ''), + trailing: + member.recipient.latestProviderLabel != null && + member + .recipient + .latestProviderLabel! + .isNotEmpty + ? Chip( + label: Text( + member.recipient.latestProviderLabel!, + style: const TextStyle(fontSize: 11), + ), + visualDensity: VisualDensity.compact, + ) + : null, + ); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ), + ); + } + + void _onDeleteSelected() async { + final userId = widget.group.userId ?? ''; + final count = batchController.model.selectedBatchIds.length; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete batches'), + content: Text('Are you sure you want to delete $count batch(es)?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + await batchController.deleteSelectedBatches( + groupId: widget.group.id ?? '', + userId: userId, + ); + } + } + + void _showFilterSheet() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return StatefulBuilder( + builder: (context, setSheetState) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Filter Batches', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + TextButton( + onPressed: () { + setSheetState(() { + _statusFilter = null; + _currencyFilter = null; + }); + }, + child: const Text('Clear All'), + ), + ], + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _statusFilter, + decoration: InputDecoration( + labelText: 'Status', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + items: _statusFilterItems, + onChanged: (v) { + setSheetState(() => _statusFilter = v); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _currencyFilter, + decoration: InputDecoration( + labelText: 'Currency', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + items: _currencyFilterItems, + onChanged: (v) { + setSheetState(() => _currencyFilter = v); + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => _applyFilters(ctx), + child: const Text('Apply Filters'), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + }, + ); + } + + void _applyFilters(BuildContext ctx) { + Navigator.of(ctx).pop(); + batchController.searchBatches( + widget.group.id ?? '', + search: _searchController.text.isNotEmpty ? _searchController.text : null, + status: _statusFilter, + currency: _currencyFilter, + ); + } + + @override + void dispose() { + _searchController.dispose(); + _scrollController.dispose(); + groupController.dispose(); + batchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.group.name), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.people_outline), + tooltip: 'View members', + onPressed: _showMembersSheet, + ), + ], + ), + body: ListenableBuilder( + listenable: batchController, + builder: (context, _) { + return Column( + children: [ + Expanded( + child: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverToBoxAdapter( + child: Center( + child: LayoutBuilder( + builder: (context, constraints) { + final width = + constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + return SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchRow(), + const SizedBox(height: 16), + _buildContent(), + ], + ), + ), + ); + }, + ), + ), + ), + ], + ), + ), + if (batchController.model.selectMode) _buildSelectionBottomBar(), + ], + ); + }, + ), + ); + } + + Widget _buildSearchRow() { + return Row( + children: [ + Expanded( + child: TextFormField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search batches by description...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ) + : null, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + onChanged: _onSearchChanged, + ), + ), + const SizedBox(width: 8), + _buildIconButton( + icon: Icons.filter_list, + tooltip: 'Filter batches', + isActive: _statusFilter != null || _currencyFilter != null, + onPressed: _showFilterSheet, + ), + const SizedBox(width: 8), + _buildIconButton( + icon: batchController.model.selectMode + ? Icons.checklist + : Icons.checklist_outlined, + tooltip: 'Select batches', + isActive: batchController.model.selectMode, + onPressed: () => batchController.toggleSelectMode(), + ), + const SizedBox(width: 8), + _buildIconButton( + icon: Icons.add, + tooltip: 'Create batch', + isActive: false, + onPressed: () async { + final result = await context.push( + '/groups/${widget.group.id}/batches/create', + extra: widget.group, + ); + if (result == true) { + _loadData(); + } + }, + ), + ], + ); + } + + Widget _buildIconButton({ + required IconData icon, + required String tooltip, + required bool isActive, + required VoidCallback onPressed, + }) { + return Container( + decoration: BoxDecoration( + color: isActive + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700), + tooltip: tooltip, + onPressed: onPressed, + ), + ); + } + + Widget _buildContent() { + if (batchController.model.isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 60), + child: CircularProgressIndicator(), + ), + ); + } + + if (batchController.model.batches.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Column( + children: [ + Icon( + Icons.receipt_long_outlined, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + const Text( + 'No batches yet.', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + 'Tap + to create a new batch payment.', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + ], + ), + ), + ); + } + + return Column( + children: [ + if (batchController.model.selectMode) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Text( + '${batchController.model.selectedBatchIds.length} selected', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + if (_statusFilter != null || _currencyFilter != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + if (_statusFilter != null) + _activeFilterChip('Status: $_statusFilter', () { + setState(() => _statusFilter = null); + _onSearchChanged(_searchController.text); + }), + if (_currencyFilter != null) const SizedBox(width: 6), + if (_currencyFilter != null) + _activeFilterChip('Currency: $_currencyFilter', () { + setState(() => _currencyFilter = null); + _onSearchChanged(_searchController.text); + }), + ], + ), + ), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: batchController.model.batches.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + return _buildBatchCard( + context, + batchController.model.batches[index], + widget.group, + ); + }, + ), + if (batchController.model.isLoadingMore) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + ], + ); + } + + Widget _activeFilterChip(String label, VoidCallback onRemove) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withAlpha(20), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withAlpha(60), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + InkWell( + onTap: onRemove, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ), + ); + } + + Widget _buildBatchCard( + BuildContext context, + GroupBatch batch, + RecipientGroup group, + ) { + final isSelected = batchController.model.selectedBatchIds.contains( + batch.id, + ); + final inSelectMode = batchController.model.selectMode; + + final status = batch.status ?? 'UNKNOWN'; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: inSelectMode && isSelected + ? Theme.of(context).colorScheme.primary + : Colors.black12.withAlpha(30), + width: inSelectMode && isSelected ? 2 : 1, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: inSelectMode + ? () => batchController.toggleBatchSelection(batch.id!) + : () => context.push('/groups/${group.id}/batches/${batch.id}'), + child: Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + if (inSelectMode) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon( + isSelected + ? Icons.check_box + : Icons.check_box_outline_blank, + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade400, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (batch.description != null && + batch.description!.isNotEmpty) + Flexible( + child: Text( + batch.description!, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + StatusChip(status: status), + ], + ), + const SizedBox(height: 4), + Text( + '${batch.currency ?? 'USD'} ${batch.value?.toStringAsFixed(2) ?? '—'}', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + if (batch.count != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + _countPill('${batch.count}', Colors.blue), + const SizedBox(width: 3), + _countPill( + '${batch.successfulCount ?? 0} ok', + Colors.green, + ), + const SizedBox(width: 3), + _countPill( + '${batch.failedCount ?? 0} failed', + Colors.red, + ), + ], + ), + ), + ], + ), + ), + if (!inSelectMode) + Icon(Icons.chevron_right, color: Colors.grey.shade400), + ], + ), + ), + ), + ); + } + + Widget _countPill(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(20), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withAlpha(60)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: color, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _buildSelectionBottomBar() { + final selectedCount = batchController.model.selectedBatchIds.length; + return Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Text( + '$selectedCount selected', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: selectedCount > 0 ? _onDeleteSelected : null, + icon: const Icon(Icons.delete_outline, size: 20), + label: const Text('Delete'), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/groups/groups_screen.dart b/lib/screens/groups/groups_screen.dart new file mode 100644 index 0000000..85debec --- /dev/null +++ b/lib/screens/groups/groups_screen.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qpay/screens/groups/models/recipient_group_model.dart'; +import 'package:qpay/models/responsive_policy.dart'; +import 'package:qpay/screens/groups/controllers/group_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class GroupsScreen extends StatefulWidget { + const GroupsScreen({super.key}); + + @override + State createState() => _GroupsScreenState(); +} + +class _GroupsScreenState extends State { + late GroupController controller; + late SharedPreferences prefs; + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + controller = GroupController(context); + _loadData(); + + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + controller.loadNextPage(userId: prefs.getString('userId') ?? ''); + } + } + + Future _loadData() async { + prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString('userId') ?? ''; + await controller.listGroups(userId: userId); + } + + void _onSearchChanged(String value) { + final userId = prefs.getString('userId') ?? ''; + controller.searchGroups( + userId: userId, + name: value.isNotEmpty ? value : null, + ); + } + + void _onDeleteSelected() async { + final userId = prefs.getString('userId') ?? ''; + final count = controller.model.selectedGroupIds.length; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete groups'), + content: Text('Are you sure you want to delete $count group(s)?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + await controller.deleteSelectedGroups(userId: userId); + } + } + + @override + void dispose() { + _searchController.dispose(); + _scrollController.dispose(); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'Groups', + style: TextStyle(fontSize: 20, color: Colors.black), + ), + centerTitle: true, + ), + body: ListenableBuilder( + listenable: controller, + builder: (context, _) { + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + child: Center( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + return SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchRow(), + const SizedBox(height: 16), + _buildContent(), + ], + ), + ), + ); + }, + ), + ), + ), + ), + if (controller.model.selectMode) _buildSelectionBottomBar(), + ], + ); + }, + ), + ); + } + + Widget _buildSearchRow() { + return Row( + children: [ + Expanded( + child: TextFormField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search groups by name...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ) + : null, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + onChanged: _onSearchChanged, + ), + ), + const SizedBox(width: 8), + _buildIconButton( + icon: controller.model.selectMode + ? Icons.checklist + : Icons.checklist_outlined, + tooltip: 'Select groups', + isActive: controller.model.selectMode, + onPressed: () => controller.toggleSelectMode(), + ), + const SizedBox(width: 8), + _buildIconButton( + icon: Icons.add, + tooltip: 'Create new group', + isActive: false, + onPressed: _createGroup, + ), + ], + ); + } + + _createGroup() async { + final response = await context.push('/groups/create'); + if (response == true) { + _loadData(); + } + } + + Widget _buildIconButton({ + required IconData icon, + required String tooltip, + required bool isActive, + required VoidCallback onPressed, + }) { + return Container( + decoration: BoxDecoration( + color: isActive + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700), + tooltip: tooltip, + onPressed: onPressed, + ), + ); + } + + Widget _buildContent() { + if (controller.model.isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 60), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.model.groups.isEmpty) { + return Center(child: _buildEmptyState()); + } + + return Column( + children: [ + if (controller.model.selectMode) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Text( + '${controller.model.selectedGroupIds.length} selected', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.model.groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + return _buildGroupCard(controller.model.groups[index]); + }, + ), + if (controller.model.isLoadingMore) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + ], + ); + } + + Widget _buildEmptyState() { + return Column( + children: [ + const SizedBox(height: 40), + Icon(Icons.group_outlined, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + const Text( + 'No groups yet.', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + 'Create a group to start sending batch payments.', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildGroupCard(RecipientGroup group) { + final isSelected = controller.model.selectedGroupIds.contains(group.id); + final inSelectMode = controller.model.selectMode; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: inSelectMode && isSelected + ? Theme.of(context).colorScheme.primary + : Colors.black12.withAlpha(30), + width: inSelectMode && isSelected ? 2 : 1, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: inSelectMode + ? () => controller.toggleGroupSelection(group.id!) + : () => context.push('/groups/${group.id}', extra: group), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (inSelectMode) + Padding( + padding: const EdgeInsets.only(right: 12), + child: Icon( + isSelected + ? Icons.check_box + : Icons.check_box_outline_blank, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade400, + ), + ), + CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withAlpha(30), + child: Text( + _getGroupAbbreviation(group.name), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + if (group.description != null && + group.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + group.description!, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (!inSelectMode) + Icon(Icons.chevron_right, color: Colors.grey.shade400), + ], + ), + ), + ), + ); + } + + String _getGroupAbbreviation(String name) { + if (name.isEmpty) return '?'; + final words = name.trim().split(RegExp(r'\s+')); + if (words.length >= 2) { + return '${words.first[0]}${words.last[0]}'.toUpperCase(); + } + return name.length >= 2 + ? name.substring(0, 2).toUpperCase() + : name[0].toUpperCase(); + } + + Widget _buildSelectionBottomBar() { + final selectedCount = controller.model.selectedGroupIds.length; + return Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Text( + '$selectedCount selected', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: selectedCount > 0 ? _onDeleteSelected : null, + icon: const Icon(Icons.delete_outline, size: 20), + label: const Text('Delete'), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/groups/models/group_batch_model.dart b/lib/screens/groups/models/group_batch_model.dart new file mode 100644 index 0000000..8f13e5b --- /dev/null +++ b/lib/screens/groups/models/group_batch_model.dart @@ -0,0 +1,143 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'group_batch_model.g.dart'; + +@JsonSerializable(explicitToJson: true) +class GroupBatch { + final String? id; + final String? recipientGroupId; + final String? userId; + final String? currency; + final String? description; + final double? value; + final String? status; + final String? paymentProcessorLabel; + final String? paymentProcessorName; + final String? paymentProcessorImage; + final String? transactionTemplate; + final String? createdAt; + final int? count; + final int? successfulCount; + final int? failedCount; + + const GroupBatch({ + this.id, + this.recipientGroupId, + this.userId, + this.currency, + this.description, + this.value, + this.status, + this.paymentProcessorLabel, + this.paymentProcessorName, + this.paymentProcessorImage, + this.transactionTemplate, + this.createdAt, + this.count, + this.successfulCount, + this.failedCount, + }); + + factory GroupBatch.fromJson(Map json) => + _$GroupBatchFromJson(json); + + Map toJson() => _$GroupBatchToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GroupBatchItem { + final String? id; + final String? groupBatchId; + final String? recipientName; + final String? recipientPhone; + final double? amount; + final String? status; + final String? transactionId; + final String? errorMessage; + final String? createdAt; + + const GroupBatchItem({ + this.id, + this.groupBatchId, + this.recipientName, + this.recipientPhone, + this.amount, + this.status, + this.transactionId, + this.errorMessage, + this.createdAt, + }); + + factory GroupBatchItem.fromJson(Map json) => + _$GroupBatchItemFromJson(json); + + Map toJson() => _$GroupBatchItemToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class CreateBatchRequest { + final String recipientGroupId; + final String userId; + final String currency; + final String description; + final double amount; + final String? paymentProcessorLabel; + final String? paymentProcessorName; + final String? paymentProcessorImage; + final Map transactionTemplate; + + const CreateBatchRequest({ + required this.recipientGroupId, + required this.userId, + required this.currency, + required this.description, + required this.amount, + this.paymentProcessorLabel, + this.paymentProcessorName, + this.paymentProcessorImage, + required this.transactionTemplate, + }); + + factory CreateBatchRequest.fromJson(Map json) => + _$CreateBatchRequestFromJson(json); + + Map toJson() => _$CreateBatchRequestToJson(this); +} + +@JsonSerializable() +class BatchActionRequest { + final String userId; + final String idempotencyKey; + + const BatchActionRequest({ + required this.userId, + required this.idempotencyKey, + }); + + factory BatchActionRequest.fromJson(Map json) => + _$BatchActionRequestFromJson(json); + + Map toJson() => _$BatchActionRequestToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GroupBatchUpdateRequest { + String? id; + String? userId; + String? paymentProcessorLabel; + String? paymentProcessorName; + String? paymentProcessorImage; + + GroupBatchUpdateRequest({ + this.id, + this.userId, + this.paymentProcessorLabel, + this.paymentProcessorName, + this.paymentProcessorImage, + }); + + factory GroupBatchUpdateRequest.fromJson(Map json) => + _$GroupBatchUpdateRequestFromJson(json); + + Map toJson() => _$GroupBatchUpdateRequestToJson(this); +} diff --git a/lib/screens/groups/models/group_batch_model.g.dart b/lib/screens/groups/models/group_batch_model.g.dart new file mode 100644 index 0000000..8caa231 --- /dev/null +++ b/lib/screens/groups/models/group_batch_model.g.dart @@ -0,0 +1,128 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'group_batch_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GroupBatch _$GroupBatchFromJson(Map json) => GroupBatch( + id: json['id'] as String?, + recipientGroupId: json['recipientGroupId'] as String?, + userId: json['userId'] as String?, + currency: json['currency'] as String?, + description: json['description'] as String?, + value: (json['value'] as num?)?.toDouble(), + status: json['status'] as String?, + paymentProcessorLabel: json['paymentProcessorLabel'] as String?, + paymentProcessorName: json['paymentProcessorName'] as String?, + paymentProcessorImage: json['paymentProcessorImage'] as String?, + transactionTemplate: json['transactionTemplate'] as String?, + createdAt: json['createdAt'] as String?, + count: (json['count'] as num?)?.toInt(), + successfulCount: (json['successfulCount'] as num?)?.toInt(), + failedCount: (json['failedCount'] as num?)?.toInt(), +); + +Map _$GroupBatchToJson(GroupBatch instance) => + { + 'id': instance.id, + 'recipientGroupId': instance.recipientGroupId, + 'userId': instance.userId, + 'currency': instance.currency, + 'description': instance.description, + 'value': instance.value, + 'status': instance.status, + 'paymentProcessorLabel': instance.paymentProcessorLabel, + 'paymentProcessorName': instance.paymentProcessorName, + 'paymentProcessorImage': instance.paymentProcessorImage, + 'transactionTemplate': instance.transactionTemplate, + 'createdAt': instance.createdAt, + 'count': instance.count, + 'successfulCount': instance.successfulCount, + 'failedCount': instance.failedCount, + }; + +GroupBatchItem _$GroupBatchItemFromJson(Map json) => + GroupBatchItem( + id: json['id'] as String?, + groupBatchId: json['groupBatchId'] as String?, + recipientName: json['recipientName'] as String?, + recipientPhone: json['recipientPhone'] as String?, + amount: (json['amount'] as num?)?.toDouble(), + status: json['status'] as String?, + transactionId: json['transactionId'] as String?, + errorMessage: json['errorMessage'] as String?, + createdAt: json['createdAt'] as String?, + ); + +Map _$GroupBatchItemToJson(GroupBatchItem instance) => + { + 'id': instance.id, + 'groupBatchId': instance.groupBatchId, + 'recipientName': instance.recipientName, + 'recipientPhone': instance.recipientPhone, + 'amount': instance.amount, + 'status': instance.status, + 'transactionId': instance.transactionId, + 'errorMessage': instance.errorMessage, + 'createdAt': instance.createdAt, + }; + +CreateBatchRequest _$CreateBatchRequestFromJson(Map json) => + CreateBatchRequest( + recipientGroupId: json['recipientGroupId'] as String, + userId: json['userId'] as String, + currency: json['currency'] as String, + description: json['description'] as String, + amount: (json['amount'] as num).toDouble(), + paymentProcessorLabel: json['paymentProcessorLabel'] as String?, + paymentProcessorName: json['paymentProcessorName'] as String?, + paymentProcessorImage: json['paymentProcessorImage'] as String?, + transactionTemplate: json['transactionTemplate'] as Map, + ); + +Map _$CreateBatchRequestToJson(CreateBatchRequest instance) => + { + 'recipientGroupId': instance.recipientGroupId, + 'userId': instance.userId, + 'currency': instance.currency, + 'description': instance.description, + 'amount': instance.amount, + 'paymentProcessorLabel': instance.paymentProcessorLabel, + 'paymentProcessorName': instance.paymentProcessorName, + 'paymentProcessorImage': instance.paymentProcessorImage, + 'transactionTemplate': instance.transactionTemplate, + }; + +BatchActionRequest _$BatchActionRequestFromJson(Map json) => + BatchActionRequest( + userId: json['userId'] as String, + idempotencyKey: json['idempotencyKey'] as String, + ); + +Map _$BatchActionRequestToJson(BatchActionRequest instance) => + { + 'userId': instance.userId, + 'idempotencyKey': instance.idempotencyKey, + }; + +GroupBatchUpdateRequest _$GroupBatchUpdateRequestFromJson( + Map json, +) => GroupBatchUpdateRequest( + id: json['id'] as String?, + userId: json['userId'] as String?, + paymentProcessorLabel: json['paymentProcessorLabel'] as String?, + paymentProcessorName: json['paymentProcessorName'] as String?, + paymentProcessorImage: json['paymentProcessorImage'] as String?, +); + +Map _$GroupBatchUpdateRequestToJson( + GroupBatchUpdateRequest instance, +) => { + 'id': instance.id, + 'userId': instance.userId, + 'paymentProcessorLabel': instance.paymentProcessorLabel, + 'paymentProcessorName': instance.paymentProcessorName, + 'paymentProcessorImage': instance.paymentProcessorImage, +}; diff --git a/lib/screens/groups/models/recipient_group_model.dart b/lib/screens/groups/models/recipient_group_model.dart new file mode 100644 index 0000000..4408ac6 --- /dev/null +++ b/lib/screens/groups/models/recipient_group_model.dart @@ -0,0 +1,72 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:qpay/screens/recipient/recipients_controller.dart'; + +part 'recipient_group_model.g.dart'; + +@JsonSerializable(explicitToJson: true) +class RecipientGroup { + final String? id; + final String name; + final String? description; + final String? userId; + final String? createdAt; + + const RecipientGroup({ + this.id, + required this.name, + this.description, + this.userId, + this.createdAt, + }); + + factory RecipientGroup.fromJson(Map json) => + _$RecipientGroupFromJson(json); + + Map toJson() => _$RecipientGroupToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class RecipientGroupMember { + final String? id; + final String recipientGroupId; + final String? recipientUid; + final String? userId; + final String? account; + final String? createdAt; + final Recipient recipient; + + const RecipientGroupMember({ + this.id, + required this.recipientGroupId, + this.recipientUid, + this.userId, + this.account, + this.createdAt, + required this.recipient, + }); + + factory RecipientGroupMember.fromJson(Map json) => + _$RecipientGroupMemberFromJson(json); + + Map toJson() => _$RecipientGroupMemberToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class CreateGroupRequest { + final String name; + final String? description; + final String userId; + final List recipients; + + const CreateGroupRequest({ + required this.name, + this.description, + required this.userId, + required this.recipients, + }); + + factory CreateGroupRequest.fromJson(Map json) => + _$CreateGroupRequestFromJson(json); + + Map toJson() => _$CreateGroupRequestToJson(this); +} diff --git a/lib/screens/groups/models/recipient_group_model.g.dart b/lib/screens/groups/models/recipient_group_model.g.dart new file mode 100644 index 0000000..6d691d5 --- /dev/null +++ b/lib/screens/groups/models/recipient_group_model.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recipient_group_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RecipientGroup _$RecipientGroupFromJson(Map json) => + RecipientGroup( + id: json['id'] as String?, + name: json['name'] as String, + description: json['description'] as String?, + userId: json['userId'] as String?, + createdAt: json['createdAt'] as String?, + ); + +Map _$RecipientGroupToJson(RecipientGroup instance) => + { + 'id': instance.id, + 'name': instance.name, + 'description': instance.description, + 'userId': instance.userId, + 'createdAt': instance.createdAt, + }; + +RecipientGroupMember _$RecipientGroupMemberFromJson( + Map json, +) => RecipientGroupMember( + id: json['id'] as String?, + recipientGroupId: json['recipientGroupId'] as String, + recipientUid: json['recipientUid'] as String?, + userId: json['userId'] as String?, + account: json['account'] as String?, + createdAt: json['createdAt'] as String?, + recipient: Recipient.fromJson(json['recipient'] as Map), +); + +Map _$RecipientGroupMemberToJson( + RecipientGroupMember instance, +) => { + 'id': instance.id, + 'recipientGroupId': instance.recipientGroupId, + 'recipientUid': instance.recipientUid, + 'userId': instance.userId, + 'account': instance.account, + 'createdAt': instance.createdAt, + 'recipient': instance.recipient.toJson(), +}; + +CreateGroupRequest _$CreateGroupRequestFromJson(Map json) => + CreateGroupRequest( + name: json['name'] as String, + description: json['description'] as String?, + userId: json['userId'] as String, + recipients: (json['recipients'] as List) + .map((e) => Recipient.fromJson(e as Map)) + .toList(), + ); + +Map _$CreateGroupRequestToJson(CreateGroupRequest instance) => + { + 'name': instance.name, + 'description': instance.description, + 'userId': instance.userId, + 'recipients': instance.recipients.map((e) => e.toJson()).toList(), + }; diff --git a/lib/screens/onboarding/complete_screen.dart b/lib/screens/onboarding/complete_screen.dart index 2daff6e..54ab4fb 100644 --- a/lib/screens/onboarding/complete_screen.dart +++ b/lib/screens/onboarding/complete_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:qpay/models/responsive_policy.dart'; -import 'package:qpay/screens/onboarding/login/login_controller.dart'; class CompleteScreen extends StatefulWidget { const CompleteScreen({super.key}); diff --git a/lib/screens/receipt/receipt_screen.dart b/lib/screens/receipt/receipt_screen.dart index bcc44b9..0ec2ec4 100644 --- a/lib/screens/receipt/receipt_screen.dart +++ b/lib/screens/receipt/receipt_screen.dart @@ -259,7 +259,7 @@ class _ReceiptScreenState extends State ), ), ), - const SizedBox(height: 24), + const SizedBox(height: 16), // Animated info card section FadeTransition( @@ -272,7 +272,7 @@ class _ReceiptScreenState extends State ), ), ), - const SizedBox(height: 20), + const SizedBox(height: 14), // Animated content section FadeTransition( @@ -386,7 +386,7 @@ class _ReceiptScreenState extends State ], stops: const [0.0, 0.5, 1.0], ), - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(20), border: Border.all( color: primaryColor.withValues(alpha: 0.12), width: 1, @@ -399,13 +399,13 @@ class _ReceiptScreenState extends State ), ], ), - padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24), + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), child: Column( children: [ // Status badge if (status != null) ...[ _buildStatusBadge(status, theme), - const SizedBox(height: 16), + const SizedBox(height: 12), ], // Amount row @@ -433,14 +433,14 @@ class _ReceiptScreenState extends State ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 8), // Bill name (if available) if (billName.isNotEmpty) Container( padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 6, + horizontal: 12, + vertical: 4, ), decoration: BoxDecoration( color: primaryColor.withValues(alpha: 0.08), @@ -455,7 +455,7 @@ class _ReceiptScreenState extends State ), ), ), - const SizedBox(height: 6), + const SizedBox(height: 4), // Date if (createdAt != null) @@ -467,11 +467,11 @@ class _ReceiptScreenState extends State color: isDark ? Colors.white54 : Colors.grey.shade500, ), ), - const SizedBox(height: 24), + const SizedBox(height: 16), // Divider with dots pattern _buildDottedDivider(isDark), - const SizedBox(height: 20), + const SizedBox(height: 14), // Action buttons row _buildActionButtons(theme), @@ -487,7 +487,7 @@ class _ReceiptScreenState extends State Widget _buildDottedDivider(bool isDark) { return Row( children: List.generate( - 60, + 40, (index) => Expanded( child: Container( height: 1.5, @@ -508,8 +508,8 @@ class _ReceiptScreenState extends State final paymentStatus = _transaction?.paymentStatus; return Wrap( - spacing: 12, - runSpacing: 8, + spacing: 10, + runSpacing: 6, alignment: WrapAlignment.center, children: [ _modernActionButton( @@ -926,7 +926,7 @@ class _ReceiptScreenState extends State ], ), child: Padding( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -954,7 +954,7 @@ class _ReceiptScreenState extends State ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), // Divider Container( @@ -963,12 +963,12 @@ class _ReceiptScreenState extends State ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade200, ), - const SizedBox(height: 16), + const SizedBox(height: 12), // Detail items ...items.map( (item) => Padding( - padding: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.only(bottom: 10), child: _buildModernDetailRow( theme: theme, isDark: isDark, @@ -980,7 +980,7 @@ class _ReceiptScreenState extends State // Total amount divider & total if (totalAmount != null) ...[ Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 2), child: Container( height: 1, decoration: BoxDecoration( @@ -993,7 +993,7 @@ class _ReceiptScreenState extends State ), ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), Row( children: [ Container( @@ -1111,4 +1111,4 @@ class _DetailItem { this.isWarning = false, this.trailing, }); -} +} \ No newline at end of file diff --git a/lib/screens/recipient/recipients_controller.dart b/lib/screens/recipient/recipients_controller.dart index 9f29833..461c371 100644 --- a/lib/screens/recipient/recipients_controller.dart +++ b/lib/screens/recipient/recipients_controller.dart @@ -20,14 +20,14 @@ class RecipientModel { @freezed abstract class Recipient with _$Recipient { const factory Recipient({ - String? uid, - String? name, - String? email, - String? phoneNumber, - String? address, - String? account, - String? initials, - String? latestProviderLabel, + @JsonKey(includeIfNull: false) String? uid, + @JsonKey(includeIfNull: false) String? name, + @JsonKey(includeIfNull: false) String? email, + @JsonKey(includeIfNull: false) String? phoneNumber, + @JsonKey(includeIfNull: false) String? address, + @JsonKey(includeIfNull: false) String? account, + @JsonKey(includeIfNull: false) String? initials, + @JsonKey(includeIfNull: false) String? latestProviderLabel, }) = _Recipient; factory Recipient.fromJson(Map json) => @@ -40,14 +40,15 @@ class RecipientsController extends ChangeNotifier { BuildContext context; RecipientsController(this.context) { - model.selectedProvider = - Provider.of( - context, - listen: false, - ).model.selectedProvider; + model.selectedProvider = Provider.of( + context, + listen: false, + ).model.selectedProvider; } - Future>> getRecipients([Map? params]) async { + Future>> getRecipients([ + Map? params, + ]) async { model.errorMessage = null; model.isLoading = true; model.recipients = getDummyRecipients(); diff --git a/lib/screens/recipient/recipients_controller.freezed.dart b/lib/screens/recipient/recipients_controller.freezed.dart index 0ad08f1..3066b54 100644 --- a/lib/screens/recipient/recipients_controller.freezed.dart +++ b/lib/screens/recipient/recipients_controller.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$Recipient { - String? get uid; String? get name; String? get email; String? get phoneNumber; String? get address; String? get account; String? get initials; String? get latestProviderLabel; +@JsonKey(includeIfNull: false) String? get uid;@JsonKey(includeIfNull: false) String? get name;@JsonKey(includeIfNull: false) String? get email;@JsonKey(includeIfNull: false) String? get phoneNumber;@JsonKey(includeIfNull: false) String? get address;@JsonKey(includeIfNull: false) String? get account;@JsonKey(includeIfNull: false) String? get initials;@JsonKey(includeIfNull: false) String? get latestProviderLabel; /// Create a copy of Recipient /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -48,7 +48,7 @@ abstract mixin class $RecipientCopyWith<$Res> { factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl; @useResult $Res call({ - String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel +@JsonKey(includeIfNull: false) String? uid,@JsonKey(includeIfNull: false) String? name,@JsonKey(includeIfNull: false) String? email,@JsonKey(includeIfNull: false) String? phoneNumber,@JsonKey(includeIfNull: false) String? address,@JsonKey(includeIfNull: false) String? account,@JsonKey(includeIfNull: false) String? initials,@JsonKey(includeIfNull: false) String? latestProviderLabel }); @@ -160,7 +160,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _Recipient() when $default != null: return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _: @@ -181,7 +181,7 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel) $default,) {final _that = this; switch (_that) { case _Recipient(): return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _: @@ -201,7 +201,7 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel)? $default,) {final _that = this; switch (_that) { case _Recipient() when $default != null: return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _: @@ -216,17 +216,17 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address @JsonSerializable() class _Recipient implements Recipient { - const _Recipient({this.uid, this.name, this.email, this.phoneNumber, this.address, this.account, this.initials, this.latestProviderLabel}); + const _Recipient({@JsonKey(includeIfNull: false) this.uid, @JsonKey(includeIfNull: false) this.name, @JsonKey(includeIfNull: false) this.email, @JsonKey(includeIfNull: false) this.phoneNumber, @JsonKey(includeIfNull: false) this.address, @JsonKey(includeIfNull: false) this.account, @JsonKey(includeIfNull: false) this.initials, @JsonKey(includeIfNull: false) this.latestProviderLabel}); factory _Recipient.fromJson(Map json) => _$RecipientFromJson(json); -@override final String? uid; -@override final String? name; -@override final String? email; -@override final String? phoneNumber; -@override final String? address; -@override final String? account; -@override final String? initials; -@override final String? latestProviderLabel; +@override@JsonKey(includeIfNull: false) final String? uid; +@override@JsonKey(includeIfNull: false) final String? name; +@override@JsonKey(includeIfNull: false) final String? email; +@override@JsonKey(includeIfNull: false) final String? phoneNumber; +@override@JsonKey(includeIfNull: false) final String? address; +@override@JsonKey(includeIfNull: false) final String? account; +@override@JsonKey(includeIfNull: false) final String? initials; +@override@JsonKey(includeIfNull: false) final String? latestProviderLabel; /// Create a copy of Recipient /// with the given fields replaced by the non-null parameter values. @@ -261,7 +261,7 @@ abstract mixin class _$RecipientCopyWith<$Res> implements $RecipientCopyWith<$Re factory _$RecipientCopyWith(_Recipient value, $Res Function(_Recipient) _then) = __$RecipientCopyWithImpl; @override @useResult $Res call({ - String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel +@JsonKey(includeIfNull: false) String? uid,@JsonKey(includeIfNull: false) String? name,@JsonKey(includeIfNull: false) String? email,@JsonKey(includeIfNull: false) String? phoneNumber,@JsonKey(includeIfNull: false) String? address,@JsonKey(includeIfNull: false) String? account,@JsonKey(includeIfNull: false) String? initials,@JsonKey(includeIfNull: false) String? latestProviderLabel }); diff --git a/lib/screens/recipient/recipients_controller.g.dart b/lib/screens/recipient/recipients_controller.g.dart index afeb606..d454257 100644 --- a/lib/screens/recipient/recipients_controller.g.dart +++ b/lib/screens/recipient/recipients_controller.g.dart @@ -19,12 +19,12 @@ _Recipient _$RecipientFromJson(Map json) => _Recipient( Map _$RecipientToJson(_Recipient instance) => { - 'uid': instance.uid, - 'name': instance.name, - 'email': instance.email, - 'phoneNumber': instance.phoneNumber, - 'address': instance.address, - 'account': instance.account, - 'initials': instance.initials, - 'latestProviderLabel': instance.latestProviderLabel, + 'uid': ?instance.uid, + 'name': ?instance.name, + 'email': ?instance.email, + 'phoneNumber': ?instance.phoneNumber, + 'address': ?instance.address, + 'account': ?instance.account, + 'initials': ?instance.initials, + 'latestProviderLabel': ?instance.latestProviderLabel, }; diff --git a/lib/widgets/status_chip_widget.dart b/lib/widgets/status_chip_widget.dart new file mode 100644 index 0000000..4e965b8 --- /dev/null +++ b/lib/widgets/status_chip_widget.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class StatusChip extends StatelessWidget { + final String status; + + const StatusChip({super.key, required this.status}); + + Color _color() { + switch (status.toUpperCase()) { + case 'SUCCESS': + case 'COMPLETE': + return Colors.green; + case 'FAILED': + return Colors.red; + case 'PROCESSING': + return Colors.orange; + case 'CONFIRMED': + return Colors.blue; + default: + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + final color = _color(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(25), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withAlpha(80)), + ), + child: Text( + status, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +}