diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md index 5c96d8736bcd..663c6004a534 100644 --- a/.github/ISSUE_TEMPLATE/Standard.md +++ b/.github/ISSUE_TEMPLATE/Standard.md @@ -43,8 +43,10 @@ Which of our officially supported platforms is this issue occurring on? ## Screenshots/Videos -Add any screenshot/video evidence +
+ Add any screenshot/video evidence +
[View all open jobs on GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22) diff --git a/android/app/build.gradle b/android/app/build.gradle index 64b903fdbe24..ae7625810a14 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009004301 - versionName "9.0.43-1" + versionCode 1009004400 + versionName "9.0.44-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 152ad1a4c5ba..4803d189074c 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.4", + "electron-updater": "^6.3.5", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, @@ -59,9 +59,10 @@ } }, "node_modules/builder-util-runtime": { - "version": "9.2.5", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.5.tgz", - "integrity": "sha512-HjIDfhvqx/8B3TDN4GbABQcgpewTU4LMRTQPkVpKYV3lsuxEJoIfvg09GyWTNmfVNSUAYf+fbTN//JX4TH20pg==", + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.6.tgz", + "integrity": "sha512-sCNP0uykVxn1vdYdPGW3+8D4kMOF8PR9eL5HgUcQXhpoIoUGxdD03yQgZcuMQt4iGLKb5DD62evElwGq1ylEag==", + "license": "MIT", "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -102,11 +103,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -154,12 +156,12 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-updater": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.4.tgz", - "integrity": "sha512-uZUo7p1Y53G4tl6Cgw07X1yF8Jlz6zhaL7CQJDZ1fVVkOaBfE2cWtx80avwDVi8jHp+I/FWawrMgTAeCCNIfAg==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.5.tgz", + "integrity": "sha512-8bUtfe3UHnM+D7971N/zo3NCG2TIHuE4GFUtHRVmVOQg0prXEd+uZoVekakdPiTDkkXJv4b09CZMw/ZJJfag1A==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.2.5", + "builder-util-runtime": "9.2.6", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -304,9 +306,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/node-machine-id": { "version": "1.1.12", @@ -335,7 +338,8 @@ "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" }, "node_modules/semver": { "version": "7.6.3", @@ -465,9 +469,9 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "builder-util-runtime": { - "version": "9.2.5", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.5.tgz", - "integrity": "sha512-HjIDfhvqx/8B3TDN4GbABQcgpewTU4LMRTQPkVpKYV3lsuxEJoIfvg09GyWTNmfVNSUAYf+fbTN//JX4TH20pg==", + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.6.tgz", + "integrity": "sha512-sCNP0uykVxn1vdYdPGW3+8D4kMOF8PR9eL5HgUcQXhpoIoUGxdD03yQgZcuMQt4iGLKb5DD62evElwGq1ylEag==", "requires": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -496,11 +500,11 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "electron-context-menu": { @@ -534,11 +538,11 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-updater": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.4.tgz", - "integrity": "sha512-uZUo7p1Y53G4tl6Cgw07X1yF8Jlz6zhaL7CQJDZ1fVVkOaBfE2cWtx80avwDVi8jHp+I/FWawrMgTAeCCNIfAg==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.5.tgz", + "integrity": "sha512-8bUtfe3UHnM+D7971N/zo3NCG2TIHuE4GFUtHRVmVOQg0prXEd+uZoVekakdPiTDkkXJv4b09CZMw/ZJJfag1A==", "requires": { - "builder-util-runtime": "9.2.5", + "builder-util-runtime": "9.2.6", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -651,9 +655,9 @@ "integrity": "sha1-mi3sg4Bvuy2XXyK+7IWcoms5OqE=" }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node-machine-id": { "version": "1.1.12", diff --git a/desktop/package.json b/desktop/package.json index 6c2158a74978..b8e1e175b0fe 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.4", + "electron-updater": "^6.3.5", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md similarity index 97% rename from docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md rename to docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md index 402337140419..a7b7ed1c4f4f 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md @@ -1,5 +1,5 @@ --- -title: Connect personal U.S. bank account +title: Connect personal bank account description: Receive reimbursements for expense reports submitted to your employer ---
diff --git a/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md b/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md index 2d2f1b5afddc..87b03e2e69ee 100644 --- a/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md +++ b/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md @@ -16,6 +16,7 @@ To enable and set per diem rates, 6. Create a .csv, .txt, .xls, or .xlsx spreadsheet containing four columns: Destination, Sub-rate, Amount, and Currency. You’ll want a different row for each location that an employee may travel to, which may include states and/or countries to help account for cost differences across various locations. Here are some example templates you can use: - [Germany rates]({{site.url}}/assets/Files/Germany-per-diem.csv) - [Sweden rates]({{site.url}}/assets/Files/Sweden-per-diem.csv) + - [Finland rates]({{site.url}}/assets/Files/Finland-per-diem.csv) - [South Africa single rates]({{site.url}}/assets/Files/South-Africa-per-diem.csv) 7. Click **Import from spreadsheet**. 8. Click **Upload** to select your spreadsheet. diff --git a/docs/articles/new-expensify/workspaces/Set-up-workflows.md b/docs/articles/new-expensify/workspaces/Set-up-workflows.md index 07d770d3ad50..7c44e3792122 100644 --- a/docs/articles/new-expensify/workspaces/Set-up-workflows.md +++ b/docs/articles/new-expensify/workspaces/Set-up-workflows.md @@ -17,6 +17,10 @@ Workflows are available for Collect and Control workspaces. Additionally, you mu 4. Click **More features** in the left menu. 5. Under the Spend section, enable the Workflows toggle. +![Click Account Settings > Workspaces > click on the workspace]({{site.url}}/assets/images/ExpensifyHelp-Workflows-1.png){:width="100%"} + +![Click More Features > Enable Workflows]({{site.url}}/assets/images/ExpensifyHelp-Workflows-2.png){:width="100%"} + # Select workflows You can choose to require additional approvals and/or allow delayed submissions. @@ -29,6 +33,8 @@ You can choose to require additional approvals and/or allow delayed submissions. -- With delayed submission **enabled**, all reimbursable and non-reimbursable expenses will be submitted at a designated frequency. -- If delay submission is **disabled**, all reimbursable and non-reimbursable expenses are submitted instantly. +![Enable workflow features]({{site.url}}/assets/images/ExpensifyHelp-Workflows-3.png){:width="100%"} + # Set up payment account The payments section is where you’ll set up your business bank account for payments of expenses and invoices. diff --git a/docs/assets/Files/Finland-per-diem.csv b/docs/assets/Files/Finland-per-diem.csv new file mode 100644 index 000000000000..beb7abc5ef62 --- /dev/null +++ b/docs/assets/Files/Finland-per-diem.csv @@ -0,0 +1,1071 @@ +Destination,Amount,Currency,Subrate +*Exceptional,12.75,EUR,1 meal (no destination) +*Exceptional,15.5,EUR,2+ Meals (no destination) +*Exceptional,18,EUR,Travel (no destination) +*Finland,51,EUR,Full day (over 10 hours) +*Finland,24,EUR,Partial day (over 6 hours) +*Finland,51,EUR,Final day (over 6 hours) +*Finland,24,EUR,Final day (over 2 hours) +*Finland,16,EUR,Night Travel supplement +*Finland,-24,EUR,1 meal +*Finland,-51,EUR,2+ Meals +Afghanistan,59,EUR,Full day (over 24 hours) +Afghanistan,59,EUR,Final day (over 10 hours) +Afghanistan,29.5,EUR,Final day (over 2 hours) +Afghanistan,-29.5,EUR,2+ Meals +Afghanistan,16,EUR,Night Travel supplement +Albania,81,EUR,Full day (over 24 hours) +Albania,81,EUR,Final day (over 10 hours) +Albania,40.5,EUR,Final day (over 2 hours) +Albania,-40.5,EUR,2+ Meals +Albania,16,EUR,Night Travel supplement +Algeria,78,EUR,Full day (over 24 hours) +Algeria,78,EUR,Final day (over 10 hours) +Algeria,39,EUR,Final day (over 2 hours) +Algeria,-39,EUR,2+ Meals +Algeria,16,EUR,Night Travel supplement +Andorra,63,EUR,Full day (over 24 hours) +Andorra,63,EUR,Final day (over 10 hours) +Andorra,31.5,EUR,Final day (over 2 hours) +Andorra,-31.5,EUR,2+ Meals +Andorra,16,EUR,Night Travel supplement +Angola,71,EUR,Full day (over 24 hours) +Angola,71,EUR,Final day (over 10 hours) +Angola,35.5,EUR,Final day (over 2 hours) +Angola,-35.5,EUR,2+ Meals +Angola,16,EUR,Night Travel supplement +Antiqua and Barbuda,94,EUR,Full day (over 24 hours) +Antiqua and Barbuda,94,EUR,Final day (over 10 hours) +Antiqua and Barbuda,47,EUR,Final day (over 2 hours) +Antiqua and Barbuda,-47,EUR,2+ Meals +Antiqua and Barbuda,16,EUR,Night Travel supplement +"Any other country, not specified above",52,EUR,Full day (over 24 hours) +"Any other country, not specified above",52,EUR,Final day (over 10 hours) +"Any other country, not specified above",26,EUR,Final day (over 2 hours) +"Any other country, not specified above",-26,EUR,2+ Meals +"Any other country, not specified above",16,EUR,Night Travel supplement +Argentina,38,EUR,Full day (over 24 hours) +Argentina,38,EUR,Final day (over 10 hours) +Argentina,19,EUR,Final day (over 2 hours) +Argentina,-19,EUR,2+ Meals +Argentina,16,EUR,Night Travel supplement +Armenia,61,EUR,Full day (over 24 hours) +Armenia,61,EUR,Final day (over 10 hours) +Armenia,30.5,EUR,Final day (over 2 hours) +Armenia,-30.5,EUR,2+ Meals +Armenia,16,EUR,Night Travel supplement +Aruba,70,EUR,Full day (over 24 hours) +Aruba,70,EUR,Final day (over 10 hours) +Aruba,35,EUR,Final day (over 2 hours) +Aruba,-35,EUR,2+ Meals +Aruba,16,EUR,Night Travel supplement +Australia,74,EUR,Full day (over 24 hours) +Australia,74,EUR,Final day (over 10 hours) +Australia,37,EUR,Final day (over 2 hours) +Australia,-37,EUR,2+ Meals +Australia,16,EUR,Night Travel supplement +Austria,80,EUR,Full day (over 24 hours) +Austria,80,EUR,Final day (over 10 hours) +Austria,40,EUR,Final day (over 2 hours) +Austria,-40,EUR,2+ Meals +Austria,16,EUR,Night Travel supplement +Azerbaidzhan,70,EUR,Full day (over 24 hours) +Azerbaidzhan,70,EUR,Final day (over 10 hours) +Azerbaidzhan,35,EUR,Final day (over 2 hours) +Azerbaidzhan,-35,EUR,2+ Meals +Azerbaidzhan,16,EUR,Night Travel supplement +Azores,69,EUR,Full day (over 24 hours) +Azores,69,EUR,Final day (over 10 hours) +Azores,34.5,EUR,Final day (over 2 hours) +Azores,-34.5,EUR,2+ Meals +Azores,16,EUR,Night Travel supplement +Bahamas,91,EUR,Full day (over 24 hours) +Bahamas,91,EUR,Final day (over 10 hours) +Bahamas,45.5,EUR,Final day (over 2 hours) +Bahamas,-45.5,EUR,2+ Meals +Bahamas,16,EUR,Night Travel supplement +Bahrain,80,EUR,Full day (over 24 hours) +Bahrain,80,EUR,Final day (over 10 hours) +Bahrain,40,EUR,Final day (over 2 hours) +Bahrain,-40,EUR,2+ Meals +Bahrain,16,EUR,Night Travel supplement +Bangladesh,57,EUR,Full day (over 24 hours) +Bangladesh,57,EUR,Final day (over 10 hours) +Bangladesh,28.5,EUR,Final day (over 2 hours) +Bangladesh,-28.5,EUR,2+ Meals +Bangladesh,16,EUR,Night Travel supplement +Barbados,83,EUR,Full day (over 24 hours) +Barbados,83,EUR,Final day (over 10 hours) +Barbados,41.5,EUR,Final day (over 2 hours) +Barbados,-41.5,EUR,2+ Meals +Barbados,16,EUR,Night Travel supplement +Belarus,63,EUR,Full day (over 24 hours) +Belarus,63,EUR,Final day (over 10 hours) +Belarus,31.5,EUR,Final day (over 2 hours) +Belarus,-31.5,EUR,2+ Meals +Belarus,16,EUR,Night Travel supplement +Belgium,77,EUR,Full day (over 24 hours) +Belgium,77,EUR,Final day (over 10 hours) +Belgium,38.5,EUR,Final day (over 2 hours) +Belgium,-38.5,EUR,2+ Meals +Belgium,16,EUR,Night Travel supplement +Belize,52,EUR,Full day (over 24 hours) +Belize,52,EUR,Final day (over 10 hours) +Belize,26,EUR,Final day (over 2 hours) +Belize,-26,EUR,2+ Meals +Belize,16,EUR,Night Travel supplement +Benin,47,EUR,Full day (over 24 hours) +Benin,47,EUR,Final day (over 10 hours) +Benin,23.5,EUR,Final day (over 2 hours) +Benin,-23.5,EUR,2+ Meals +Benin,16,EUR,Night Travel supplement +Bermuda,90,EUR,Full day (over 24 hours) +Bermuda,90,EUR,Final day (over 10 hours) +Bermuda,45,EUR,Final day (over 2 hours) +Bermuda,-45,EUR,2+ Meals +Bermuda,16,EUR,Night Travel supplement +Bhutan,49,EUR,Full day (over 24 hours) +Bhutan,49,EUR,Final day (over 10 hours) +Bhutan,24.5,EUR,Final day (over 2 hours) +Bhutan,-24.5,EUR,2+ Meals +Bhutan,16,EUR,Night Travel supplement +Bolivia,48,EUR,Full day (over 24 hours) +Bolivia,48,EUR,Final day (over 10 hours) +Bolivia,24,EUR,Final day (over 2 hours) +Bolivia,-24,EUR,2+ Meals +Bolivia,16,EUR,Night Travel supplement +Bosnia and Hercegovina,54,EUR,Full day (over 24 hours) +Bosnia and Hercegovina,54,EUR,Final day (over 10 hours) +Bosnia and Hercegovina,27,EUR,Final day (over 2 hours) +Bosnia and Hercegovina,-27,EUR,2+ Meals +Bosnia and Hercegovina,16,EUR,Night Travel supplement +Botswana,41,EUR,Full day (over 24 hours) +Botswana,41,EUR,Final day (over 10 hours) +Botswana,20.5,EUR,Final day (over 2 hours) +Botswana,-20.5,EUR,2+ Meals +Botswana,16,EUR,Night Travel supplement +Brazil,80,EUR,Full day (over 24 hours) +Brazil,80,EUR,Final day (over 10 hours) +Brazil,40,EUR,Final day (over 2 hours) +Brazil,-40,EUR,2+ Meals +Brazil,16,EUR,Night Travel supplement +Brunei,45,EUR,Full day (over 24 hours) +Brunei,45,EUR,Final day (over 10 hours) +Brunei,22.5,EUR,Final day (over 2 hours) +Brunei,-22.5,EUR,2+ Meals +Brunei,16,EUR,Night Travel supplement +Bulgaria,64,EUR,Full day (over 24 hours) +Bulgaria,64,EUR,Final day (over 10 hours) +Bulgaria,32,EUR,Final day (over 2 hours) +Bulgaria,-32,EUR,2+ Meals +Bulgaria,16,EUR,Night Travel supplement +Burkina Faso,40,EUR,Full day (over 24 hours) +Burkina Faso,40,EUR,Final day (over 10 hours) +Burkina Faso,20,EUR,Final day (over 2 hours) +Burkina Faso,-20,EUR,2+ Meals +Burkina Faso,16,EUR,Night Travel supplement +Burundi,46,EUR,Full day (over 24 hours) +Burundi,46,EUR,Final day (over 10 hours) +Burundi,23,EUR,Final day (over 2 hours) +Burundi,-23,EUR,2+ Meals +Burundi,16,EUR,Night Travel supplement +Cambodia,67,EUR,Full day (over 24 hours) +Cambodia,67,EUR,Final day (over 10 hours) +Cambodia,33.5,EUR,Final day (over 2 hours) +Cambodia,-33.5,EUR,2+ Meals +Cambodia,16,EUR,Night Travel supplement +Cameroon,59,EUR,Full day (over 24 hours) +Cameroon,59,EUR,Final day (over 10 hours) +Cameroon,29.5,EUR,Final day (over 2 hours) +Cameroon,-29.5,EUR,2+ Meals +Cameroon,16,EUR,Night Travel supplement +Canada,82,EUR,Full day (over 24 hours) +Canada,82,EUR,Final day (over 10 hours) +Canada,41,EUR,Final day (over 2 hours) +Canada,-41,EUR,2+ Meals +Canada,16,EUR,Night Travel supplement +Canary Islands,71,EUR,Full day (over 24 hours) +Canary Islands,71,EUR,Final day (over 10 hours) +Canary Islands,35.5,EUR,Final day (over 2 hours) +Canary Islands,-35.5,EUR,2+ Meals +Canary Islands,16,EUR,Night Travel supplement +Cape Verde,45,EUR,Full day (over 24 hours) +Cape Verde,45,EUR,Final day (over 10 hours) +Cape Verde,22.5,EUR,Final day (over 2 hours) +Cape Verde,-22.5,EUR,2+ Meals +Cape Verde,16,EUR,Night Travel supplement +Central African Republic,101,EUR,Full day (over 24 hours) +Central African Republic,101,EUR,Final day (over 10 hours) +Central African Republic,50.5,EUR,Final day (over 2 hours) +Central African Republic,-50.5,EUR,2+ Meals +Central African Republic,16,EUR,Night Travel supplement +Chad,47,EUR,Full day (over 24 hours) +Chad,47,EUR,Final day (over 10 hours) +Chad,23.5,EUR,Final day (over 2 hours) +Chad,-23.5,EUR,2+ Meals +Chad,16,EUR,Night Travel supplement +Chile,56,EUR,Full day (over 24 hours) +Chile,56,EUR,Final day (over 10 hours) +Chile,28,EUR,Final day (over 2 hours) +Chile,-28,EUR,2+ Meals +Chile,16,EUR,Night Travel supplement +China,74,EUR,Full day (over 24 hours) +China,74,EUR,Final day (over 10 hours) +China,37,EUR,Final day (over 2 hours) +China,-37,EUR,2+ Meals +China,16,EUR,Night Travel supplement +Colombia,64,EUR,Full day (over 24 hours) +Colombia,64,EUR,Final day (over 10 hours) +Colombia,32,EUR,Final day (over 2 hours) +Colombia,-32,EUR,2+ Meals +Colombia,16,EUR,Night Travel supplement +Comoros,42,EUR,Full day (over 24 hours) +Comoros,42,EUR,Final day (over 10 hours) +Comoros,21,EUR,Final day (over 2 hours) +Comoros,-21,EUR,2+ Meals +Comoros,16,EUR,Night Travel supplement +Congo (Congo-Brazzaville),64,EUR,Full day (over 24 hours) +Congo (Congo-Brazzaville),64,EUR,Final day (over 10 hours) +Congo (Congo-Brazzaville),32,EUR,Final day (over 2 hours) +Congo (Congo-Brazzaville),-32,EUR,2+ Meals +Congo (Congo-Brazzaville),16,EUR,Night Travel supplement +"Congo, Democratic Republic of (Congo-Kinshasa)",51,EUR,Full day (over 24 hours) +"Congo, Democratic Republic of (Congo-Kinshasa)",51,EUR,Final day (over 10 hours) +"Congo, Democratic Republic of (Congo-Kinshasa)",25.5,EUR,Final day (over 2 hours) +"Congo, Democratic Republic of (Congo-Kinshasa)",-25.5,EUR,2+ Meals +"Congo, Democratic Republic of (Congo-Kinshasa)",16,EUR,Night Travel supplement +Cook Islands,70,EUR,Full day (over 24 hours) +Cook Islands,70,EUR,Final day (over 10 hours) +Cook Islands,35,EUR,Final day (over 2 hours) +Cook Islands,-35,EUR,2+ Meals +Cook Islands,16,EUR,Night Travel supplement +Costa Rica,65,EUR,Full day (over 24 hours) +Costa Rica,65,EUR,Final day (over 10 hours) +Costa Rica,32.5,EUR,Final day (over 2 hours) +Costa Rica,-32.5,EUR,2+ Meals +Costa Rica,16,EUR,Night Travel supplement +"Côte d’Ivoire, Ivory Coast",80,EUR,Full day (over 24 hours) +"Côte d’Ivoire, Ivory Coast",80,EUR,Final day (over 10 hours) +"Côte d’Ivoire, Ivory Coast",40,EUR,Final day (over 2 hours) +"Côte d’Ivoire, Ivory Coast",-40,EUR,2+ Meals +"Côte d’Ivoire, Ivory Coast",16,EUR,Night Travel supplement +Croatia,69,EUR,Full day (over 24 hours) +Croatia,69,EUR,Final day (over 10 hours) +Croatia,34.5,EUR,Final day (over 2 hours) +Croatia,-34.5,EUR,2+ Meals +Croatia,16,EUR,Night Travel supplement +Cuba,68,EUR,Full day (over 24 hours) +Cuba,68,EUR,Final day (over 10 hours) +Cuba,34,EUR,Final day (over 2 hours) +Cuba,-34,EUR,2+ Meals +Cuba,16,EUR,Night Travel supplement +Curaçao,58,EUR,Full day (over 24 hours) +Curaçao,58,EUR,Final day (over 10 hours) +Curaçao,29,EUR,Final day (over 2 hours) +Curaçao,-29,EUR,2+ Meals +Curaçao,16,EUR,Night Travel supplement +Cyprus,65,EUR,Full day (over 24 hours) +Cyprus,65,EUR,Final day (over 10 hours) +Cyprus,32.5,EUR,Final day (over 2 hours) +Cyprus,-32.5,EUR,2+ Meals +Cyprus,16,EUR,Night Travel supplement +Czech Republic,89,EUR,Full day (over 24 hours) +Czech Republic,89,EUR,Final day (over 10 hours) +Czech Republic,44.5,EUR,Final day (over 2 hours) +Czech Republic,-44.5,EUR,2+ Meals +Czech Republic,16,EUR,Night Travel supplement +Denmark,79,EUR,Full day (over 24 hours) +Denmark,79,EUR,Final day (over 10 hours) +Denmark,39.5,EUR,Final day (over 2 hours) +Denmark,-39.5,EUR,2+ Meals +Denmark,16,EUR,Night Travel supplement +Djibouti,83,EUR,Full day (over 24 hours) +Djibouti,83,EUR,Final day (over 10 hours) +Djibouti,41.5,EUR,Final day (over 2 hours) +Djibouti,-41.5,EUR,2+ Meals +Djibouti,16,EUR,Night Travel supplement +Dominica,61,EUR,Full day (over 24 hours) +Dominica,61,EUR,Final day (over 10 hours) +Dominica,30.5,EUR,Final day (over 2 hours) +Dominica,-30.5,EUR,2+ Meals +Dominica,16,EUR,Night Travel supplement +Dominican Republic,53,EUR,Full day (over 24 hours) +Dominican Republic,53,EUR,Final day (over 10 hours) +Dominican Republic,26.5,EUR,Final day (over 2 hours) +Dominican Republic,-26.5,EUR,2+ Meals +Dominican Republic,16,EUR,Night Travel supplement +East Timor,46,EUR,Full day (over 24 hours) +East Timor,46,EUR,Final day (over 10 hours) +East Timor,23,EUR,Final day (over 2 hours) +East Timor,-23,EUR,2+ Meals +East Timor,16,EUR,Night Travel supplement +Ecuador,63,EUR,Full day (over 24 hours) +Ecuador,63,EUR,Final day (over 10 hours) +Ecuador,31.5,EUR,Final day (over 2 hours) +Ecuador,-31.5,EUR,2+ Meals +Ecuador,16,EUR,Night Travel supplement +Egypt,66,EUR,Full day (over 24 hours) +Egypt,66,EUR,Final day (over 10 hours) +Egypt,33,EUR,Final day (over 2 hours) +Egypt,-33,EUR,2+ Meals +Egypt,16,EUR,Night Travel supplement +El Salvador,60,EUR,Full day (over 24 hours) +El Salvador,60,EUR,Final day (over 10 hours) +El Salvador,30,EUR,Final day (over 2 hours) +El Salvador,-30,EUR,2+ Meals +El Salvador,16,EUR,Night Travel supplement +Eritrea,95,EUR,Full day (over 24 hours) +Eritrea,95,EUR,Final day (over 10 hours) +Eritrea,47.5,EUR,Final day (over 2 hours) +Eritrea,-47.5,EUR,2+ Meals +Eritrea,16,EUR,Night Travel supplement +Estonia,75,EUR,Full day (over 24 hours) +Estonia,75,EUR,Final day (over 10 hours) +Estonia,37.5,EUR,Final day (over 2 hours) +Estonia,-37.5,EUR,2+ Meals +Estonia,16,EUR,Night Travel supplement +Eswatini,37,EUR,Full day (over 24 hours) +Eswatini,37,EUR,Final day (over 10 hours) +Eswatini,18.5,EUR,Final day (over 2 hours) +Eswatini,-18.5,EUR,2+ Meals +Eswatini,16,EUR,Night Travel supplement +Ethiopia,49,EUR,Full day (over 24 hours) +Ethiopia,49,EUR,Final day (over 10 hours) +Ethiopia,24.5,EUR,Final day (over 2 hours) +Ethiopia,-24.5,EUR,2+ Meals +Ethiopia,16,EUR,Night Travel supplement +Faroe Islands,61,EUR,Full day (over 24 hours) +Faroe Islands,61,EUR,Final day (over 10 hours) +Faroe Islands,30.5,EUR,Final day (over 2 hours) +Faroe Islands,-30.5,EUR,2+ Meals +Faroe Islands,16,EUR,Night Travel supplement +Fiji,52,EUR,Full day (over 24 hours) +Fiji,52,EUR,Final day (over 10 hours) +Fiji,26,EUR,Final day (over 2 hours) +Fiji,-26,EUR,2+ Meals +Fiji,16,EUR,Night Travel supplement +France,78,EUR,Full day (over 24 hours) +France,78,EUR,Final day (over 10 hours) +France,39,EUR,Final day (over 2 hours) +France,-39,EUR,2+ Meals +France,16,EUR,Night Travel supplement +Gabon,92,EUR,Full day (over 24 hours) +Gabon,92,EUR,Final day (over 10 hours) +Gabon,46,EUR,Final day (over 2 hours) +Gabon,-46,EUR,2+ Meals +Gabon,16,EUR,Night Travel supplement +Gambia,46,EUR,Full day (over 24 hours) +Gambia,46,EUR,Final day (over 10 hours) +Gambia,23,EUR,Final day (over 2 hours) +Gambia,-23,EUR,2+ Meals +Gambia,16,EUR,Night Travel supplement +Georgia,49,EUR,Full day (over 24 hours) +Georgia,49,EUR,Final day (over 10 hours) +Georgia,24.5,EUR,Final day (over 2 hours) +Georgia,-24.5,EUR,2+ Meals +Georgia,16,EUR,Night Travel supplement +Germany,76,EUR,Full day (over 24 hours) +Germany,76,EUR,Final day (over 10 hours) +Germany,38,EUR,Final day (over 2 hours) +Germany,-38,EUR,2+ Meals +Germany,16,EUR,Night Travel supplement +Ghana,47,EUR,Full day (over 24 hours) +Ghana,47,EUR,Final day (over 10 hours) +Ghana,23.5,EUR,Final day (over 2 hours) +Ghana,-23.5,EUR,2+ Meals +Ghana,16,EUR,Night Travel supplement +Greece,68,EUR,Full day (over 24 hours) +Greece,68,EUR,Final day (over 10 hours) +Greece,34,EUR,Final day (over 2 hours) +Greece,-34,EUR,2+ Meals +Greece,16,EUR,Night Travel supplement +Greenland,63,EUR,Full day (over 24 hours) +Greenland,63,EUR,Final day (over 10 hours) +Greenland,31.5,EUR,Final day (over 2 hours) +Greenland,-31.5,EUR,2+ Meals +Greenland,16,EUR,Night Travel supplement +Grenada,73,EUR,Full day (over 24 hours) +Grenada,73,EUR,Final day (over 10 hours) +Grenada,36.5,EUR,Final day (over 2 hours) +Grenada,-36.5,EUR,2+ Meals +Grenada,16,EUR,Night Travel supplement +Guadeloupe,53,EUR,Full day (over 24 hours) +Guadeloupe,53,EUR,Final day (over 10 hours) +Guadeloupe,26.5,EUR,Final day (over 2 hours) +Guadeloupe,-26.5,EUR,2+ Meals +Guadeloupe,16,EUR,Night Travel supplement +Guatemala,76,EUR,Full day (over 24 hours) +Guatemala,76,EUR,Final day (over 10 hours) +Guatemala,38,EUR,Final day (over 2 hours) +Guatemala,-38,EUR,2+ Meals +Guatemala,16,EUR,Night Travel supplement +Guinea,83,EUR,Full day (over 24 hours) +Guinea,83,EUR,Final day (over 10 hours) +Guinea,41.5,EUR,Final day (over 2 hours) +Guinea,-41.5,EUR,2+ Meals +Guinea,16,EUR,Night Travel supplement +Guinea-Bissau,41,EUR,Full day (over 24 hours) +Guinea-Bissau,41,EUR,Final day (over 10 hours) +Guinea-Bissau,20.5,EUR,Final day (over 2 hours) +Guinea-Bissau,-20.5,EUR,2+ Meals +Guinea-Bissau,16,EUR,Night Travel supplement +Guyana,51,EUR,Full day (over 24 hours) +Guyana,51,EUR,Final day (over 10 hours) +Guyana,25.5,EUR,Final day (over 2 hours) +Guyana,-25.5,EUR,2+ Meals +Guyana,16,EUR,Night Travel supplement +Haiti,62,EUR,Full day (over 24 hours) +Haiti,62,EUR,Final day (over 10 hours) +Haiti,31,EUR,Final day (over 2 hours) +Haiti,-31,EUR,2+ Meals +Haiti,16,EUR,Night Travel supplement +Honduras,58,EUR,Full day (over 24 hours) +Honduras,58,EUR,Final day (over 10 hours) +Honduras,29,EUR,Final day (over 2 hours) +Honduras,-29,EUR,2+ Meals +Honduras,16,EUR,Night Travel supplement +Hong Kong,86,EUR,Full day (over 24 hours) +Hong Kong,86,EUR,Final day (over 10 hours) +Hong Kong,43,EUR,Final day (over 2 hours) +Hong Kong,-43,EUR,2+ Meals +Hong Kong,16,EUR,Night Travel supplement +Hungary,69,EUR,Full day (over 24 hours) +Hungary,69,EUR,Final day (over 10 hours) +Hungary,34.5,EUR,Final day (over 2 hours) +Hungary,-34.5,EUR,2+ Meals +Hungary,16,EUR,Night Travel supplement +Iceland,92,EUR,Full day (over 24 hours) +Iceland,92,EUR,Final day (over 10 hours) +Iceland,46,EUR,Final day (over 2 hours) +Iceland,-46,EUR,2+ Meals +Iceland,16,EUR,Night Travel supplement +India,62,EUR,Full day (over 24 hours) +India,62,EUR,Final day (over 10 hours) +India,31,EUR,Final day (over 2 hours) +India,-31,EUR,2+ Meals +India,16,EUR,Night Travel supplement +Indonesia,57,EUR,Full day (over 24 hours) +Indonesia,57,EUR,Final day (over 10 hours) +Indonesia,28.5,EUR,Final day (over 2 hours) +Indonesia,-28.5,EUR,2+ Meals +Indonesia,16,EUR,Night Travel supplement +Iran,102,EUR,Full day (over 24 hours) +Iran,102,EUR,Final day (over 10 hours) +Iran,51,EUR,Final day (over 2 hours) +Iran,-51,EUR,2+ Meals +Iran,16,EUR,Night Travel supplement +Iraq,70,EUR,Full day (over 24 hours) +Iraq,70,EUR,Final day (over 10 hours) +Iraq,35,EUR,Final day (over 2 hours) +Iraq,-35,EUR,2+ Meals +Iraq,16,EUR,Night Travel supplement +Ireland,78,EUR,Full day (over 24 hours) +Ireland,78,EUR,Final day (over 10 hours) +Ireland,39,EUR,Final day (over 2 hours) +Ireland,-39,EUR,2+ Meals +Ireland,16,EUR,Night Travel supplement +Israel,88,EUR,Full day (over 24 hours) +Israel,88,EUR,Final day (over 10 hours) +Israel,44,EUR,Final day (over 2 hours) +Israel,-44,EUR,2+ Meals +Israel,16,EUR,Night Travel supplement +Istanbul,37,EUR,Full day (over 24 hours) +Istanbul,37,EUR,Final day (over 10 hours) +Istanbul,18.5,EUR,Final day (over 2 hours) +Istanbul,-18.5,EUR,2+ Meals +Istanbul,16,EUR,Night Travel supplement +Italy,76,EUR,Full day (over 24 hours) +Italy,76,EUR,Final day (over 10 hours) +Italy,38,EUR,Final day (over 2 hours) +Italy,-38,EUR,2+ Meals +Italy,16,EUR,Night Travel supplement +"Ivory Coast, Côte d’Ivoire",80,EUR,Full day (over 24 hours) +"Ivory Coast, Côte d’Ivoire",80,EUR,Final day (over 10 hours) +"Ivory Coast, Côte d’Ivoire",40,EUR,Final day (over 2 hours) +"Ivory Coast, Côte d’Ivoire",-40,EUR,2+ Meals +"Ivory Coast, Côte d’Ivoire",16,EUR,Night Travel supplement +Jamaica,62,EUR,Full day (over 24 hours) +Jamaica,62,EUR,Final day (over 10 hours) +Jamaica,31,EUR,Final day (over 2 hours) +Jamaica,-31,EUR,2+ Meals +Jamaica,16,EUR,Night Travel supplement +Japan,66,EUR,Full day (over 24 hours) +Japan,66,EUR,Final day (over 10 hours) +Japan,33,EUR,Final day (over 2 hours) +Japan,-33,EUR,2+ Meals +Japan,16,EUR,Night Travel supplement +Jordania,90,EUR,Full day (over 24 hours) +Jordania,90,EUR,Final day (over 10 hours) +Jordania,45,EUR,Final day (over 2 hours) +Jordania,-45,EUR,2+ Meals +Jordania,16,EUR,Night Travel supplement +Kazakhstan,59,EUR,Full day (over 24 hours) +Kazakhstan,59,EUR,Final day (over 10 hours) +Kazakhstan,29.5,EUR,Final day (over 2 hours) +Kazakhstan,-29.5,EUR,2+ Meals +Kazakhstan,16,EUR,Night Travel supplement +Kenya,70,EUR,Full day (over 24 hours) +Kenya,70,EUR,Final day (over 10 hours) +Kenya,35,EUR,Final day (over 2 hours) +Kenya,-35,EUR,2+ Meals +Kenya,16,EUR,Night Travel supplement +"Korea, Democratic People's Republic (North Korea)",70,EUR,Full day (over 24 hours) +"Korea, Democratic People's Republic (North Korea)",70,EUR,Final day (over 10 hours) +"Korea, Democratic People's Republic (North Korea)",35,EUR,Final day (over 2 hours) +"Korea, Democratic People's Republic (North Korea)",-35,EUR,2+ Meals +"Korea, Democratic People's Republic (North Korea)",16,EUR,Night Travel supplement +"Korea, Republic of (South Korea)",87,EUR,Full day (over 24 hours) +"Korea, Republic of (South Korea)",87,EUR,Final day (over 10 hours) +"Korea, Republic of (South Korea)",43.5,EUR,Final day (over 2 hours) +"Korea, Republic of (South Korea)",-43.5,EUR,2+ Meals +"Korea, Republic of (South Korea)",16,EUR,Night Travel supplement +Kosovo,58,EUR,Full day (over 24 hours) +Kosovo,58,EUR,Final day (over 10 hours) +Kosovo,29,EUR,Final day (over 2 hours) +Kosovo,-29,EUR,2+ Meals +Kosovo,16,EUR,Night Travel supplement +Kuwait,84,EUR,Full day (over 24 hours) +Kuwait,84,EUR,Final day (over 10 hours) +Kuwait,42,EUR,Final day (over 2 hours) +Kuwait,-42,EUR,2+ Meals +Kuwait,16,EUR,Night Travel supplement +Kyrgystan,41,EUR,Full day (over 24 hours) +Kyrgystan,41,EUR,Final day (over 10 hours) +Kyrgystan,20.5,EUR,Final day (over 2 hours) +Kyrgystan,-20.5,EUR,2+ Meals +Kyrgystan,16,EUR,Night Travel supplement +Laos,32,EUR,Full day (over 24 hours) +Laos,32,EUR,Final day (over 10 hours) +Laos,16,EUR,Final day (over 2 hours) +Laos,-16,EUR,2+ Meals +Laos,16,EUR,Night Travel supplement +Latvia,73,EUR,Full day (over 24 hours) +Latvia,73,EUR,Final day (over 10 hours) +Latvia,36.5,EUR,Final day (over 2 hours) +Latvia,-36.5,EUR,2+ Meals +Latvia,16,EUR,Night Travel supplement +Lebanon,102,EUR,Full day (over 24 hours) +Lebanon,102,EUR,Final day (over 10 hours) +Lebanon,51,EUR,Final day (over 2 hours) +Lebanon,-51,EUR,2+ Meals +Lebanon,16,EUR,Night Travel supplement +Lesotho,34,EUR,Full day (over 24 hours) +Lesotho,34,EUR,Final day (over 10 hours) +Lesotho,17,EUR,Final day (over 2 hours) +Lesotho,-17,EUR,2+ Meals +Lesotho,16,EUR,Night Travel supplement +Liberia,60,EUR,Full day (over 24 hours) +Liberia,60,EUR,Final day (over 10 hours) +Liberia,30,EUR,Final day (over 2 hours) +Liberia,-30,EUR,2+ Meals +Liberia,16,EUR,Night Travel supplement +Libya,52,EUR,Full day (over 24 hours) +Libya,52,EUR,Final day (over 10 hours) +Libya,26,EUR,Final day (over 2 hours) +Libya,-26,EUR,2+ Meals +Libya,16,EUR,Night Travel supplement +Liechtenstein,79,EUR,Full day (over 24 hours) +Liechtenstein,79,EUR,Final day (over 10 hours) +Liechtenstein,39.5,EUR,Final day (over 2 hours) +Liechtenstein,-39.5,EUR,2+ Meals +Liechtenstein,16,EUR,Night Travel supplement +Lithuania,72,EUR,Full day (over 24 hours) +Lithuania,72,EUR,Final day (over 10 hours) +Lithuania,36,EUR,Final day (over 2 hours) +Lithuania,-36,EUR,2+ Meals +Lithuania,16,EUR,Night Travel supplement +London and Edinburgh,83,EUR,Full day (over 24 hours) +London and Edinburgh,83,EUR,Final day (over 10 hours) +London and Edinburgh,41.5,EUR,Final day (over 2 hours) +London and Edinburgh,-41.5,EUR,2+ Meals +London and Edinburgh,16,EUR,Night Travel supplement +Luxembourg,77,EUR,Full day (over 24 hours) +Luxembourg,77,EUR,Final day (over 10 hours) +Luxembourg,38.5,EUR,Final day (over 2 hours) +Luxembourg,-38.5,EUR,2+ Meals +Luxembourg,16,EUR,Night Travel supplement +Madagascar,45,EUR,Full day (over 24 hours) +Madagascar,45,EUR,Final day (over 10 hours) +Madagascar,22.5,EUR,Final day (over 2 hours) +Madagascar,-22.5,EUR,2+ Meals +Madagascar,16,EUR,Night Travel supplement +Madeira,68,EUR,Full day (over 24 hours) +Madeira,68,EUR,Final day (over 10 hours) +Madeira,34,EUR,Final day (over 2 hours) +Madeira,-34,EUR,2+ Meals +Madeira,16,EUR,Night Travel supplement +Malawi,77,EUR,Full day (over 24 hours) +Malawi,77,EUR,Final day (over 10 hours) +Malawi,38.5,EUR,Final day (over 2 hours) +Malawi,-38.5,EUR,2+ Meals +Malawi,16,EUR,Night Travel supplement +Malaysia,50,EUR,Full day (over 24 hours) +Malaysia,50,EUR,Final day (over 10 hours) +Malaysia,25,EUR,Final day (over 2 hours) +Malaysia,-25,EUR,2+ Meals +Malaysia,16,EUR,Night Travel supplement +Maldives,68,EUR,Full day (over 24 hours) +Maldives,68,EUR,Final day (over 10 hours) +Maldives,34,EUR,Final day (over 2 hours) +Maldives,-34,EUR,2+ Meals +Maldives,16,EUR,Night Travel supplement +Mali,47,EUR,Full day (over 24 hours) +Mali,47,EUR,Final day (over 10 hours) +Mali,23.5,EUR,Final day (over 2 hours) +Mali,-23.5,EUR,2+ Meals +Mali,16,EUR,Night Travel supplement +Malta,71,EUR,Full day (over 24 hours) +Malta,71,EUR,Final day (over 10 hours) +Malta,35.5,EUR,Final day (over 2 hours) +Malta,-35.5,EUR,2+ Meals +Malta,16,EUR,Night Travel supplement +Marshall Islands,65,EUR,Full day (over 24 hours) +Marshall Islands,65,EUR,Final day (over 10 hours) +Marshall Islands,32.5,EUR,Final day (over 2 hours) +Marshall Islands,-32.5,EUR,2+ Meals +Marshall Islands,16,EUR,Night Travel supplement +Martinique,55,EUR,Full day (over 24 hours) +Martinique,55,EUR,Final day (over 10 hours) +Martinique,27.5,EUR,Final day (over 2 hours) +Martinique,-27.5,EUR,2+ Meals +Martinique,16,EUR,Night Travel supplement +Mauritania,52,EUR,Full day (over 24 hours) +Mauritania,52,EUR,Final day (over 10 hours) +Mauritania,26,EUR,Final day (over 2 hours) +Mauritania,-26,EUR,2+ Meals +Mauritania,16,EUR,Night Travel supplement +Mauritius,53,EUR,Full day (over 24 hours) +Mauritius,53,EUR,Final day (over 10 hours) +Mauritius,26.5,EUR,Final day (over 2 hours) +Mauritius,-26.5,EUR,2+ Meals +Mauritius,16,EUR,Night Travel supplement +Mexico,81,EUR,Full day (over 24 hours) +Mexico,81,EUR,Final day (over 10 hours) +Mexico,40.5,EUR,Final day (over 2 hours) +Mexico,-40.5,EUR,2+ Meals +Mexico,16,EUR,Night Travel supplement +Micronesia,59,EUR,Full day (over 24 hours) +Micronesia,59,EUR,Final day (over 10 hours) +Micronesia,29.5,EUR,Final day (over 2 hours) +Micronesia,-29.5,EUR,2+ Meals +Micronesia,16,EUR,Night Travel supplement +Moldova,73,EUR,Full day (over 24 hours) +Moldova,73,EUR,Final day (over 10 hours) +Moldova,36.5,EUR,Final day (over 2 hours) +Moldova,-36.5,EUR,2+ Meals +Moldova,16,EUR,Night Travel supplement +Monaco,92,EUR,Full day (over 24 hours) +Monaco,92,EUR,Final day (over 10 hours) +Monaco,46,EUR,Final day (over 2 hours) +Monaco,-46,EUR,2+ Meals +Monaco,16,EUR,Night Travel supplement +Mongolia,42,EUR,Full day (over 24 hours) +Mongolia,42,EUR,Final day (over 10 hours) +Mongolia,21,EUR,Final day (over 2 hours) +Mongolia,-21,EUR,2+ Meals +Mongolia,16,EUR,Night Travel supplement +Montenegro,66,EUR,Full day (over 24 hours) +Montenegro,66,EUR,Final day (over 10 hours) +Montenegro,33,EUR,Final day (over 2 hours) +Montenegro,-33,EUR,2+ Meals +Montenegro,16,EUR,Night Travel supplement +Morocco,71,EUR,Full day (over 24 hours) +Morocco,71,EUR,Final day (over 10 hours) +Morocco,35.5,EUR,Final day (over 2 hours) +Morocco,-35.5,EUR,2+ Meals +Morocco,16,EUR,Night Travel supplement +Moscow,82,EUR,Full day (over 24 hours) +Moscow,82,EUR,Final day (over 10 hours) +Moscow,41,EUR,Final day (over 2 hours) +Moscow,-41,EUR,2+ Meals +Moscow,16,EUR,Night Travel supplement +Mozambique,53,EUR,Full day (over 24 hours) +Mozambique,53,EUR,Final day (over 10 hours) +Mozambique,26.5,EUR,Final day (over 2 hours) +Mozambique,-26.5,EUR,2+ Meals +Mozambique,16,EUR,Night Travel supplement +Myanmar (formerly Burma),58,EUR,Full day (over 24 hours) +Myanmar (formerly Burma),58,EUR,Final day (over 10 hours) +Myanmar (formerly Burma),29,EUR,Final day (over 2 hours) +Myanmar (formerly Burma),-29,EUR,2+ Meals +Myanmar (formerly Burma),16,EUR,Night Travel supplement +Namibia,36,EUR,Full day (over 24 hours) +Namibia,36,EUR,Final day (over 10 hours) +Namibia,18,EUR,Final day (over 2 hours) +Namibia,-18,EUR,2+ Meals +Namibia,16,EUR,Night Travel supplement +Nepal,51,EUR,Full day (over 24 hours) +Nepal,51,EUR,Final day (over 10 hours) +Nepal,25.5,EUR,Final day (over 2 hours) +Nepal,-25.5,EUR,2+ Meals +Nepal,16,EUR,Night Travel supplement +Netherlands,83,EUR,Full day (over 24 hours) +Netherlands,83,EUR,Final day (over 10 hours) +Netherlands,41.5,EUR,Final day (over 2 hours) +Netherlands,-41.5,EUR,2+ Meals +Netherlands,16,EUR,Night Travel supplement +"New York, Los Angeles, Washington",97,EUR,Full day (over 24 hours) +"New York, Los Angeles, Washington",97,EUR,Final day (over 10 hours) +"New York, Los Angeles, Washington",48.5,EUR,Final day (over 2 hours) +"New York, Los Angeles, Washington",-48.5,EUR,2+ Meals +"New York, Los Angeles, Washington",16,EUR,Night Travel supplement +New Zealand,74,EUR,Full day (over 24 hours) +New Zealand,74,EUR,Final day (over 10 hours) +New Zealand,37,EUR,Final day (over 2 hours) +New Zealand,-37,EUR,2+ Meals +New Zealand,16,EUR,Night Travel supplement +Nicaragua,51,EUR,Full day (over 24 hours) +Nicaragua,51,EUR,Final day (over 10 hours) +Nicaragua,25.5,EUR,Final day (over 2 hours) +Nicaragua,-25.5,EUR,2+ Meals +Nicaragua,16,EUR,Night Travel supplement +Niger,50,EUR,Full day (over 24 hours) +Niger,50,EUR,Final day (over 10 hours) +Niger,25,EUR,Final day (over 2 hours) +Niger,-25,EUR,2+ Meals +Niger,16,EUR,Night Travel supplement +Nigeria,78,EUR,Full day (over 24 hours) +Nigeria,78,EUR,Final day (over 10 hours) +Nigeria,39,EUR,Final day (over 2 hours) +Nigeria,-39,EUR,2+ Meals +Nigeria,16,EUR,Night Travel supplement +North Macedonia,64,EUR,Full day (over 24 hours) +North Macedonia,64,EUR,Final day (over 10 hours) +North Macedonia,32,EUR,Final day (over 2 hours) +North Macedonia,-32,EUR,2+ Meals +North Macedonia,16,EUR,Night Travel supplement +Norway,70,EUR,Full day (over 24 hours) +Norway,70,EUR,Final day (over 10 hours) +Norway,35,EUR,Final day (over 2 hours) +Norway,-35,EUR,2+ Meals +Norway,16,EUR,Night Travel supplement +Oman,74,EUR,Full day (over 24 hours) +Oman,74,EUR,Final day (over 10 hours) +Oman,37,EUR,Final day (over 2 hours) +Oman,-37,EUR,2+ Meals +Oman,16,EUR,Night Travel supplement +Pakistan,29,EUR,Full day (over 24 hours) +Pakistan,29,EUR,Final day (over 10 hours) +Pakistan,14.5,EUR,Final day (over 2 hours) +Pakistan,-14.5,EUR,2+ Meals +Pakistan,16,EUR,Night Travel supplement +Palau,99,EUR,Full day (over 24 hours) +Palau,99,EUR,Final day (over 10 hours) +Palau,49.5,EUR,Final day (over 2 hours) +Palau,-49.5,EUR,2+ Meals +Palau,16,EUR,Night Travel supplement +Palestinian territory,76,EUR,Full day (over 24 hours) +Palestinian territory,76,EUR,Final day (over 10 hours) +Palestinian territory,38,EUR,Final day (over 2 hours) +Palestinian territory,-38,EUR,2+ Meals +Palestinian territory,16,EUR,Night Travel supplement +Panama,61,EUR,Full day (over 24 hours) +Panama,61,EUR,Final day (over 10 hours) +Panama,30.5,EUR,Final day (over 2 hours) +Panama,-30.5,EUR,2+ Meals +Panama,16,EUR,Night Travel supplement +Papua New Guinea,76,EUR,Full day (over 24 hours) +Papua New Guinea,76,EUR,Final day (over 10 hours) +Papua New Guinea,38,EUR,Final day (over 2 hours) +Papua New Guinea,-38,EUR,2+ Meals +Papua New Guinea,16,EUR,Night Travel supplement +Paraguay,36,EUR,Full day (over 24 hours) +Paraguay,36,EUR,Final day (over 10 hours) +Paraguay,18,EUR,Final day (over 2 hours) +Paraguay,-18,EUR,2+ Meals +Paraguay,16,EUR,Night Travel supplement +Peru,52,EUR,Full day (over 24 hours) +Peru,52,EUR,Final day (over 10 hours) +Peru,26,EUR,Final day (over 2 hours) +Peru,-26,EUR,2+ Meals +Peru,16,EUR,Night Travel supplement +Philippines,69,EUR,Full day (over 24 hours) +Philippines,69,EUR,Final day (over 10 hours) +Philippines,34.5,EUR,Final day (over 2 hours) +Philippines,-34.5,EUR,2+ Meals +Philippines,16,EUR,Night Travel supplement +Poland,72,EUR,Full day (over 24 hours) +Poland,72,EUR,Final day (over 10 hours) +Poland,36,EUR,Final day (over 2 hours) +Poland,-36,EUR,2+ Meals +Poland,16,EUR,Night Travel supplement +Portugal,70,EUR,Full day (over 24 hours) +Portugal,70,EUR,Final day (over 10 hours) +Portugal,35,EUR,Final day (over 2 hours) +Portugal,-35,EUR,2+ Meals +Portugal,16,EUR,Night Travel supplement +Puerto Rico,70,EUR,Full day (over 24 hours) +Puerto Rico,70,EUR,Final day (over 10 hours) +Puerto Rico,35,EUR,Final day (over 2 hours) +Puerto Rico,-35,EUR,2+ Meals +Puerto Rico,16,EUR,Night Travel supplement +Qatar,78,EUR,Full day (over 24 hours) +Qatar,78,EUR,Final day (over 10 hours) +Qatar,39,EUR,Final day (over 2 hours) +Qatar,-39,EUR,2+ Meals +Qatar,16,EUR,Night Travel supplement +Romania,68,EUR,Full day (over 24 hours) +Romania,68,EUR,Final day (over 10 hours) +Romania,34,EUR,Final day (over 2 hours) +Romania,-34,EUR,2+ Meals +Romania,16,EUR,Night Travel supplement +Russian Federation,66,EUR,Full day (over 24 hours) +Russian Federation,66,EUR,Final day (over 10 hours) +Russian Federation,33,EUR,Final day (over 2 hours) +Russian Federation,-33,EUR,2+ Meals +Russian Federation,16,EUR,Night Travel supplement +Rwanda,37,EUR,Full day (over 24 hours) +Rwanda,37,EUR,Final day (over 10 hours) +Rwanda,18.5,EUR,Final day (over 2 hours) +Rwanda,-18.5,EUR,2+ Meals +Rwanda,16,EUR,Night Travel supplement +Saint Kitts and Nevis,68,EUR,Full day (over 24 hours) +Saint Kitts and Nevis,68,EUR,Final day (over 10 hours) +Saint Kitts and Nevis,34,EUR,Final day (over 2 hours) +Saint Kitts and Nevis,-34,EUR,2+ Meals +Saint Kitts and Nevis,16,EUR,Night Travel supplement +Saint Lucia,86,EUR,Full day (over 24 hours) +Saint Lucia,86,EUR,Final day (over 10 hours) +Saint Lucia,43,EUR,Final day (over 2 hours) +Saint Lucia,-43,EUR,2+ Meals +Saint Lucia,16,EUR,Night Travel supplement +Saint Vincent and the Grenadines,85,EUR,Full day (over 24 hours) +Saint Vincent and the Grenadines,85,EUR,Final day (over 10 hours) +Saint Vincent and the Grenadines,42.5,EUR,Final day (over 2 hours) +Saint Vincent and the Grenadines,-42.5,EUR,2+ Meals +Saint Vincent and the Grenadines,16,EUR,Night Travel supplement +Samoa,61,EUR,Full day (over 24 hours) +Samoa,61,EUR,Final day (over 10 hours) +Samoa,30.5,EUR,Final day (over 2 hours) +Samoa,-30.5,EUR,2+ Meals +Samoa,16,EUR,Night Travel supplement +San Marino,59,EUR,Full day (over 24 hours) +San Marino,59,EUR,Final day (over 10 hours) +San Marino,29.5,EUR,Final day (over 2 hours) +San Marino,-29.5,EUR,2+ Meals +San Marino,16,EUR,Night Travel supplement +Sao Tome and Principe,102,EUR,Full day (over 24 hours) +Sao Tome and Principe,102,EUR,Final day (over 10 hours) +Sao Tome and Principe,51,EUR,Final day (over 2 hours) +Sao Tome and Principe,-51,EUR,2+ Meals +Sao Tome and Principe,16,EUR,Night Travel supplement +Saudi Arabia,80,EUR,Full day (over 24 hours) +Saudi Arabia,80,EUR,Final day (over 10 hours) +Saudi Arabia,40,EUR,Final day (over 2 hours) +Saudi Arabia,-40,EUR,2+ Meals +Saudi Arabia,16,EUR,Night Travel supplement +Senegal,58,EUR,Full day (over 24 hours) +Senegal,58,EUR,Final day (over 10 hours) +Senegal,29,EUR,Final day (over 2 hours) +Senegal,-29,EUR,2+ Meals +Senegal,16,EUR,Night Travel supplement +Serbia,75,EUR,Full day (over 24 hours) +Serbia,75,EUR,Final day (over 10 hours) +Serbia,37.5,EUR,Final day (over 2 hours) +Serbia,-37.5,EUR,2+ Meals +Serbia,16,EUR,Night Travel supplement +Seychelles,87,EUR,Full day (over 24 hours) +Seychelles,87,EUR,Final day (over 10 hours) +Seychelles,43.5,EUR,Final day (over 2 hours) +Seychelles,-43.5,EUR,2+ Meals +Seychelles,16,EUR,Night Travel supplement +Sierra Leone,47,EUR,Full day (over 24 hours) +Sierra Leone,47,EUR,Final day (over 10 hours) +Sierra Leone,23.5,EUR,Final day (over 2 hours) +Sierra Leone,-23.5,EUR,2+ Meals +Sierra Leone,16,EUR,Night Travel supplement +Singapore,79,EUR,Full day (over 24 hours) +Singapore,79,EUR,Final day (over 10 hours) +Singapore,39.5,EUR,Final day (over 2 hours) +Singapore,-39.5,EUR,2+ Meals +Singapore,16,EUR,Night Travel supplement +Slovakia,79,EUR,Full day (over 24 hours) +Slovakia,79,EUR,Final day (over 10 hours) +Slovakia,39.5,EUR,Final day (over 2 hours) +Slovakia,-39.5,EUR,2+ Meals +Slovakia,16,EUR,Night Travel supplement +Slovenia,72,EUR,Full day (over 24 hours) +Slovenia,72,EUR,Final day (over 10 hours) +Slovenia,36,EUR,Final day (over 2 hours) +Slovenia,-36,EUR,2+ Meals +Slovenia,16,EUR,Night Travel supplement +Solomon Islands,63,EUR,Full day (over 24 hours) +Solomon Islands,63,EUR,Final day (over 10 hours) +Solomon Islands,31.5,EUR,Final day (over 2 hours) +Solomon Islands,-31.5,EUR,2+ Meals +Solomon Islands,16,EUR,Night Travel supplement +Somalia,86,EUR,Full day (over 24 hours) +Somalia,86,EUR,Final day (over 10 hours) +Somalia,43,EUR,Final day (over 2 hours) +Somalia,-43,EUR,2+ Meals +Somalia,16,EUR,Night Travel supplement +South Africa,50,EUR,Full day (over 24 hours) +South Africa,50,EUR,Final day (over 10 hours) +South Africa,25,EUR,Final day (over 2 hours) +South Africa,-25,EUR,2+ Meals +South Africa,16,EUR,Night Travel supplement +South Sudan,102,EUR,Full day (over 24 hours) +South Sudan,102,EUR,Final day (over 10 hours) +South Sudan,51,EUR,Final day (over 2 hours) +South Sudan,-51,EUR,2+ Meals +South Sudan,16,EUR,Night Travel supplement +Spain,74,EUR,Full day (over 24 hours) +Spain,74,EUR,Final day (over 10 hours) +Spain,37,EUR,Final day (over 2 hours) +Spain,-37,EUR,2+ Meals +Spain,16,EUR,Night Travel supplement +Sri Lanka,29,EUR,Full day (over 24 hours) +Sri Lanka,29,EUR,Final day (over 10 hours) +Sri Lanka,14.5,EUR,Final day (over 2 hours) +Sri Lanka,-14.5,EUR,2+ Meals +Sri Lanka,16,EUR,Night Travel supplement +St. Petersburg,76,EUR,Full day (over 24 hours) +St. Petersburg,76,EUR,Final day (over 10 hours) +St. Petersburg,38,EUR,Final day (over 2 hours) +St. Petersburg,-38,EUR,2+ Meals +St. Petersburg,16,EUR,Night Travel supplement +Sudan,83,EUR,Full day (over 24 hours) +Sudan,83,EUR,Final day (over 10 hours) +Sudan,41.5,EUR,Final day (over 2 hours) +Sudan,-41.5,EUR,2+ Meals +Sudan,16,EUR,Night Travel supplement +Suriname,78,EUR,Full day (over 24 hours) +Suriname,78,EUR,Final day (over 10 hours) +Suriname,39,EUR,Final day (over 2 hours) +Suriname,-39,EUR,2+ Meals +Suriname,16,EUR,Night Travel supplement +Sweden,64,EUR,Full day (over 24 hours) +Sweden,64,EUR,Final day (over 10 hours) +Sweden,32,EUR,Final day (over 2 hours) +Sweden,-32,EUR,2+ Meals +Sweden,16,EUR,Night Travel supplement +Switzerland,93,EUR,Full day (over 24 hours) +Switzerland,93,EUR,Final day (over 10 hours) +Switzerland,46.5,EUR,Final day (over 2 hours) +Switzerland,-46.5,EUR,2+ Meals +Switzerland,16,EUR,Night Travel supplement +Syria,91,EUR,Full day (over 24 hours) +Syria,91,EUR,Final day (over 10 hours) +Syria,45.5,EUR,Final day (over 2 hours) +Syria,-45.5,EUR,2+ Meals +Syria,16,EUR,Night Travel supplement +Tadzhikistan,35,EUR,Full day (over 24 hours) +Tadzhikistan,35,EUR,Final day (over 10 hours) +Tadzhikistan,17.5,EUR,Final day (over 2 hours) +Tadzhikistan,-17.5,EUR,2+ Meals +Tadzhikistan,16,EUR,Night Travel supplement +Taiwan,69,EUR,Full day (over 24 hours) +Taiwan,69,EUR,Final day (over 10 hours) +Taiwan,34.5,EUR,Final day (over 2 hours) +Taiwan,-34.5,EUR,2+ Meals +Taiwan,16,EUR,Night Travel supplement +Tanzania,54,EUR,Full day (over 24 hours) +Tanzania,54,EUR,Final day (over 10 hours) +Tanzania,27,EUR,Final day (over 2 hours) +Tanzania,-27,EUR,2+ Meals +Tanzania,16,EUR,Night Travel supplement +Thailand,63,EUR,Full day (over 24 hours) +Thailand,63,EUR,Final day (over 10 hours) +Thailand,31.5,EUR,Final day (over 2 hours) +Thailand,-31.5,EUR,2+ Meals +Thailand,16,EUR,Night Travel supplement +Togo,58,EUR,Full day (over 24 hours) +Togo,58,EUR,Final day (over 10 hours) +Togo,29,EUR,Final day (over 2 hours) +Togo,-29,EUR,2+ Meals +Togo,16,EUR,Night Travel supplement +Tonga,62,EUR,Full day (over 24 hours) +Tonga,62,EUR,Final day (over 10 hours) +Tonga,31,EUR,Final day (over 2 hours) +Tonga,-31,EUR,2+ Meals +Tonga,16,EUR,Night Travel supplement +Trinidad and Tobago,83,EUR,Full day (over 24 hours) +Trinidad and Tobago,83,EUR,Final day (over 10 hours) +Trinidad and Tobago,41.5,EUR,Final day (over 2 hours) +Trinidad and Tobago,-41.5,EUR,2+ Meals +Trinidad and Tobago,16,EUR,Night Travel supplement +Tunisia,61,EUR,Full day (over 24 hours) +Tunisia,61,EUR,Final day (over 10 hours) +Tunisia,30.5,EUR,Final day (over 2 hours) +Tunisia,-30.5,EUR,2+ Meals +Tunisia,16,EUR,Night Travel supplement +Turkey,35,EUR,Full day (over 24 hours) +Turkey,35,EUR,Final day (over 10 hours) +Turkey,17.5,EUR,Final day (over 2 hours) +Turkey,-17.5,EUR,2+ Meals +Turkey,16,EUR,Night Travel supplement +Turkmenistan,92,EUR,Full day (over 24 hours) +Turkmenistan,92,EUR,Final day (over 10 hours) +Turkmenistan,46,EUR,Final day (over 2 hours) +Turkmenistan,-46,EUR,2+ Meals +Turkmenistan,16,EUR,Night Travel supplement +Uganda,49,EUR,Full day (over 24 hours) +Uganda,49,EUR,Final day (over 10 hours) +Uganda,24.5,EUR,Final day (over 2 hours) +Uganda,-24.5,EUR,2+ Meals +Uganda,16,EUR,Night Travel supplement +Ukraine,64,EUR,Full day (over 24 hours) +Ukraine,64,EUR,Final day (over 10 hours) +Ukraine,32,EUR,Final day (over 2 hours) +Ukraine,-32,EUR,2+ Meals +Ukraine,16,EUR,Night Travel supplement +United Arab Emirates,73,EUR,Full day (over 24 hours) +United Arab Emirates,73,EUR,Final day (over 10 hours) +United Arab Emirates,36.5,EUR,Final day (over 2 hours) +United Arab Emirates,-36.5,EUR,2+ Meals +United Arab Emirates,16,EUR,Night Travel supplement +United Kingdom,79,EUR,Full day (over 24 hours) +United Kingdom,79,EUR,Final day (over 10 hours) +United Kingdom,39.5,EUR,Final day (over 2 hours) +United Kingdom,-39.5,EUR,2+ Meals +United Kingdom,16,EUR,Night Travel supplement +United States,89,EUR,Full day (over 24 hours) +United States,89,EUR,Final day (over 10 hours) +United States,44.5,EUR,Final day (over 2 hours) +United States,-44.5,EUR,2+ Meals +United States,16,EUR,Night Travel supplement +Uruguay,59,EUR,Full day (over 24 hours) +Uruguay,59,EUR,Final day (over 10 hours) +Uruguay,29.5,EUR,Final day (over 2 hours) +Uruguay,-29.5,EUR,2+ Meals +Uruguay,16,EUR,Night Travel supplement +Uzbekistan,32,EUR,Full day (over 24 hours) +Uzbekistan,32,EUR,Final day (over 10 hours) +Uzbekistan,16,EUR,Final day (over 2 hours) +Uzbekistan,-16,EUR,2+ Meals +Uzbekistan,16,EUR,Night Travel supplement +Vanuatu,70,EUR,Full day (over 24 hours) +Vanuatu,70,EUR,Final day (over 10 hours) +Vanuatu,35,EUR,Final day (over 2 hours) +Vanuatu,-35,EUR,2+ Meals +Vanuatu,16,EUR,Night Travel supplement +Venezuela,102,EUR,Full day (over 24 hours) +Venezuela,102,EUR,Final day (over 10 hours) +Venezuela,51,EUR,Final day (over 2 hours) +Venezuela,-51,EUR,2+ Meals +Venezuela,16,EUR,Night Travel supplement +Viet Nam,69,EUR,Full day (over 24 hours) +Viet Nam,69,EUR,Final day (over 10 hours) +Viet Nam,34.5,EUR,Final day (over 2 hours) +Viet Nam,-34.5,EUR,2+ Meals +Viet Nam,16,EUR,Night Travel supplement +Virgin Islands (USA),64,EUR,Full day (over 24 hours) +Virgin Islands (USA),64,EUR,Final day (over 10 hours) +Virgin Islands (USA),32,EUR,Final day (over 2 hours) +Virgin Islands (USA),-32,EUR,2+ Meals +Virgin Islands (USA),16,EUR,Night Travel supplement +Yemen,102,EUR,Full day (over 24 hours) +Yemen,102,EUR,Final day (over 10 hours) +Yemen,51,EUR,Final day (over 2 hours) +Yemen,-51,EUR,2+ Meals +Yemen,16,EUR,Night Travel supplement +Zambia,55,EUR,Full day (over 24 hours) +Zambia,55,EUR,Final day (over 10 hours) +Zambia,27.5,EUR,Final day (over 2 hours) +Zambia,-27.5,EUR,2+ Meals +Zambia,16,EUR,Night Travel supplement +Zimbabwe,102,EUR,Full day (over 24 hours) +Zimbabwe,102,EUR,Final day (over 10 hours) +Zimbabwe,51,EUR,Final day (over 2 hours) +Zimbabwe,-51,EUR,2+ Meals +Zimbabwe,16,EUR,Night Travel supplement diff --git a/docs/redirects.csv b/docs/redirects.csv index b47d6f2ae25c..0a5007b4fa61 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -577,4 +577,5 @@ https://help.expensify.com/articles/new-expensify/expenses-&-payments/pay-an-inv https://community.expensify.com/discussion/4707/how-to-set-up-your-mobile-app,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace#download-the-mobile-app https://community.expensify.com//discussion/6927/deep-dive-how-can-i-estimate-the-savings-applied-to-my-bill,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview#savings-calculator https://community.expensify.com/discussion/5179/faq-what-does-a-policy-for-which-you-are-an-admin-has-out-of-date-billing-information-mean,https://help.expensify.com/articles/expensify-classic/expensify-billing/Out-of-date-Billing -https://community.expensify.com/discussion/6179/setting-up-a-receipt-or-travel-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/connections/Additional-Travel-Integrations \ No newline at end of file +https://community.expensify.com/discussion/6179/setting-up-a-receipt-or-travel-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/connections/Additional-Travel-Integrations +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index a62749c0629e..43cae757b784 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.43 + 9.0.44 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.43.1 + 9.0.44.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 16d99226f854..b1715a829e2a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.43 + 9.0.44 CFBundleSignature ???? CFBundleVersion - 9.0.43.1 + 9.0.44.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 16c7823a1cf8..7b8d7e40f1f6 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.43 + 9.0.44 CFBundleVersion - 9.0.43.1 + 9.0.44.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/NotificationServiceExtension/NotificationService.swift b/ios/NotificationServiceExtension/NotificationService.swift index b3c56a36619d..e489cb368d17 100644 --- a/ios/NotificationServiceExtension/NotificationService.swift +++ b/ios/NotificationServiceExtension/NotificationService.swift @@ -8,18 +8,12 @@ import AirshipServiceExtension import os.log import Intents -import AppLogs class NotificationService: UANotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? let log = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "com.expensify.chat.dev.NotificationServiceExtension", category: "NotificationService") - let appLogs: AppLogs = .init() - - deinit { - appLogs.forwardLogsTo(appGroup: "group.com.expensify.new") - } override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { os_log("[NotificationService] didReceive() - received notification", log: log) @@ -48,7 +42,7 @@ class NotificationService: UANotificationServiceExtension { do { notificationData = try parsePayload(notificationContent: notificationContent) } catch ExpError.runtimeError(let errorMessage) { - os_log("[NotificationService] configureCommunicationNotification() - couldn't parse the payload '%{public}@'", log: log, type: .error, errorMessage) + os_log("[NotificationService] configureCommunicationNotification() - couldn't parse the payload '%@'", log: log, type: .error, errorMessage) contentHandler(notificationContent) return } catch { @@ -218,7 +212,7 @@ class NotificationService: UANotificationServiceExtension { let data = try Data(contentsOf: url) return INImage(imageData: data) } catch { - os_log("[NotificationService] fetchINImage() - failed to fetch avatar. reportActionID: %{public}@", log: self.log, type: .error, reportActionID) + os_log("[NotificationService] fetchINImage() - failed to fetch avatar. reportActionID: %@", log: self.log, type: .error, reportActionID) return nil } } diff --git a/ios/Podfile b/ios/Podfile index 4d139711ef01..e807089c26b9 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -119,7 +119,6 @@ end target 'NotificationServiceExtension' do pod 'AirshipServiceExtension' - pod 'AppLogs', :path => '../node_modules/react-native-app-logs/AppLogsPod' end pod 'FullStory', :http => 'https://ios-releases.fullstory.com/fullstory-1.52.0-xcframework.tar.gz' \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 64f8e0365423..beac64acd083 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -26,7 +26,6 @@ PODS: - AppAuth/Core (1.7.5) - AppAuth/ExternalUserAgent (1.7.5): - AppAuth/Core - - AppLogs (0.1.0) - boost (1.84.0) - DoubleConversion (1.1.6) - EXAV (14.0.7): @@ -1565,27 +1564,6 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-app-logs (0.2.2): - - DoubleConversion - - glog - - hermes-engine - - RCT-Folly (= 2024.01.01.00) - - RCTRequired - - RCTTypeSafety - - React-Core - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-NativeModulesApple - - React-RCTFabric - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - Yoga - react-native-blob-util (0.19.4): - DoubleConversion - glog @@ -1968,7 +1946,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-view-shot (4.0.0-alpha.3): + - react-native-view-shot (3.8.0): - React-Core - react-native-webview (13.8.6): - DoubleConversion @@ -2711,7 +2689,6 @@ PODS: DEPENDENCIES: - AirshipServiceExtension - - AppLogs (from `../node_modules/react-native-app-logs/AppLogsPod`) - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - EXAV (from `../node_modules/expo-av/ios`) @@ -2761,7 +2738,6 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - "react-native-airship (from `../node_modules/@ua/react-native-airship`)" - - react-native-app-logs (from `../node_modules/react-native-app-logs`) - react-native-blob-util (from `../node_modules/react-native-blob-util`) - "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)" - react-native-config (from `../node_modules/react-native-config`) @@ -2875,8 +2851,6 @@ SPEC REPOS: - Turf EXTERNAL SOURCES: - AppLogs: - :path: "../node_modules/react-native-app-logs/AppLogsPod" boost: :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" DoubleConversion: @@ -2972,8 +2946,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-airship: :path: "../node_modules/@ua/react-native-airship" - react-native-app-logs: - :path: "../node_modules/react-native-app-logs" react-native-blob-util: :path: "../node_modules/react-native-blob-util" react-native-cameraroll: @@ -3124,7 +3096,6 @@ SPEC CHECKSUMS: AirshipFrameworkProxy: dbd862dc6fb21b13e8b196458d626123e2a43a50 AirshipServiceExtension: 9c73369f426396d9fb9ff222d86d842fac76ba46 AppAuth: 501c04eda8a8d11f179dbe8637b7a91bb7e5d2fa - AppLogs: 3bc4e9b141dbf265b9464409caaa40416a9ee0e0 boost: 26992d1adf73c1c7676360643e687aee6dda994b DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 EXAV: afa491e598334bbbb92a92a2f4dd33d7149ad37f @@ -3200,7 +3171,6 @@ SPEC CHECKSUMS: React-Mapbuffer: 1c08607305558666fd16678b85ef135e455d5c96 React-microtasksnativemodule: f13f03163b6a5ec66665dfe80a0df4468bb766a6 react-native-airship: e10f6823d8da49bbcb2db4bdb16ff954188afccc - react-native-app-logs: 91a04f691f2db7c1d6153bce31cab3922e6873f4 react-native-blob-util: 221c61c98ae507b758472ac4d2d489119d1a6c44 react-native-cameraroll: 478a0c1fcdd39f08f6ac272b7ed06e92b2c7c129 react-native-config: 5ce986133b07fc258828b20b9506de0e683efc1c @@ -3218,7 +3188,7 @@ SPEC CHECKSUMS: react-native-quick-sqlite: 7c793c9f5834e756b336257a8d8b8239b7ceb451 react-native-release-profiler: 131ec5e4145d900b2be2a8d6641e2ce0dd784259 react-native-safe-area-context: 38fdd9b3c5561de7cabae64bd0cd2ce05d2768a1 - react-native-view-shot: ee44129a7c470310d3c7e67085834fc8cc077655 + react-native-view-shot: 6b7ed61d77d88580fed10954d45fad0eb2d47688 react-native-webview: ad29375839c9aa0409ce8e8693291b42bdc067a4 React-nativeconfig: 57781b79e11d5af7573e6f77cbf1143b71802a6d React-NativeModulesApple: 7ff2e2cfb2e5fa5bdedcecf28ce37e696c6ef1e1 @@ -3276,8 +3246,8 @@ SPEC CHECKSUMS: SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Turf: aa2ede4298009639d10db36aba1a7ebaad072a5e VisionCamera: c6c8aa4b028501fc87644550fbc35a537d4da3fb - Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae + Yoga: a1d7895431387402a674fd0d1c04ec85e87909b8 -PODFILE CHECKSUM: 15e2f095b9c80d658459723edf84005a6867debf +PODFILE CHECKSUM: a07e55247056ec5d84d1af31d694506efff3cfe2 COCOAPODS: 1.15.2 diff --git a/jest/setup.ts b/jest/setup.ts index 7dbe91c32fda..6901ad3c66f3 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,6 +1,5 @@ /* eslint-disable max-classes-per-file */ import '@shopify/flash-list/jestSetup'; -import type * as RNAppLogs from 'react-native-app-logs'; import 'react-native-gesture-handler/jestSetup'; import type * as RNKeyboardController from 'react-native-keyboard-controller'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; @@ -76,8 +75,6 @@ jest.mock('react-native-reanimated', () => ({ jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); -jest.mock('react-native-app-logs', () => require('react-native-app-logs/jest')); - jest.mock('@src/libs/actions/Timing', () => ({ start: jest.fn(), end: jest.fn(), diff --git a/package-lock.json b/package-lock.json index c46a6987c49a..33baf6a35084 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.43-1", + "version": "9.0.44-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.43-1", + "version": "9.0.44-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -75,7 +75,6 @@ "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", - "react-native-app-logs": "git+https://github.com/margelo/react-native-app-logs#4653bc25b600497c5c64f2897f9778c796193238", "react-native-blob-util": "0.19.4", "react-native-collapsible": "^1.6.2", "react-native-config": "1.5.0", @@ -210,6 +209,7 @@ "copy-webpack-plugin": "^10.1.0", "css-loader": "^6.7.2", "csv-parse": "^5.5.5", + "csv-writer": "^1.6.0", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", "electron": "^29.4.6", @@ -21225,6 +21225,12 @@ "dev": true, "license": "MIT" }, + "node_modules/csv-writer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz", + "integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==", + "dev": true + }, "node_modules/dag-map": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz", @@ -34382,18 +34388,6 @@ "prop-types": "^15.7.2" } }, - "node_modules/react-native-app-logs": { - "version": "0.2.2", - "resolved": "git+ssh://git@github.com/margelo/react-native-app-logs.git#4653bc25b600497c5c64f2897f9778c796193238", - "integrity": "sha512-nPZhRCtobnGQB9rm0q4vxNWVNtyU5vgR/9wfg8KHaZgp6Bqb7jMTljZLXNJKPewhlQhvf0u4b/cHlt/CkMyU9Q==", - "workspaces": [ - "example" - ], - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/react-native-blob-util": { "version": "0.19.4", "license": "MIT", diff --git a/package.json b/package.json index 645767ec1b44..527a293a6a9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.43-1", + "version": "9.0.44-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -132,7 +132,6 @@ "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", - "react-native-app-logs": "git+https://github.com/margelo/react-native-app-logs#4653bc25b600497c5c64f2897f9778c796193238", "react-native-blob-util": "0.19.4", "react-native-collapsible": "^1.6.2", "react-native-config": "1.5.0", @@ -267,6 +266,7 @@ "copy-webpack-plugin": "^10.1.0", "css-loader": "^6.7.2", "csv-parse": "^5.5.5", + "csv-writer": "^1.6.0", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", "electron": "^29.4.6", diff --git a/patches/react-native-pdf+6.7.3.patch b/patches/react-native-pdf+6.7.3+001+initial.patch similarity index 100% rename from patches/react-native-pdf+6.7.3.patch rename to patches/react-native-pdf+6.7.3+001+initial.patch diff --git a/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch b/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch new file mode 100644 index 000000000000..1061335b85fe --- /dev/null +++ b/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-pdf/index.js b/node_modules/react-native-pdf/index.js +index bea7af8..bf767c9 100644 +--- a/node_modules/react-native-pdf/index.js ++++ b/node_modules/react-native-pdf/index.js +@@ -233,7 +233,7 @@ export default class Pdf extends Component { + } else { + if (this._mounted) { + this.setState({ +- path: unescape(uri.replace(/file:\/\//i, '')), ++ path: decodeURIComponent(uri.replace(/file:\/\//i, '')), + isDownloaded: true, + }); + } diff --git a/scripts/aggregateGitHubDataFromUpwork.ts b/scripts/aggregateGitHubDataFromUpwork.ts new file mode 100644 index 000000000000..f47b2b43e5cc --- /dev/null +++ b/scripts/aggregateGitHubDataFromUpwork.ts @@ -0,0 +1,175 @@ +/** + * This script is used for categorizing upwork costs into cost buckets for accounting purposes. + * + * To run this script from the root of E/App: + * + * ts-node ./scripts/aggregateGitHubDataFromUpwork.js + * + * The input file must be a CSV with a single column containing just the GitHub issue number. The CSV must have a single header row. + */ +import {getOctokitOptions, GitHub} from '@actions/github/lib/utils'; +import {paginateRest} from '@octokit/plugin-paginate-rest'; +import {throttling} from '@octokit/plugin-throttling'; +import {createObjectCsvWriter} from 'csv-writer'; +import fs from 'fs'; + +type OctokitOptions = {method: string; url: string; request: {retryCount: number}}; +type IssueType = 'bug' | 'feature' | 'other'; + +if (process.argv.length < 3) { + throw new Error('Error: must provide filepath for CSV data'); +} + +if (process.argv.length < 4) { + throw new Error('Error: must provide GitHub token'); +} + +if (process.argv.length < 5) { + throw new Error('Error: must provide output file path'); +} + +// Get filepath for csv +const inputFilepath = process.argv.at(2); +if (!inputFilepath) { + throw new Error('Error: must provide filepath for CSV data'); +} + +// Get GitHub token +const token = (process.argv.at(3) ?? '').trim(); +if (!token) { + throw new Error('Error: must provide GitHub token'); +} + +const Octokit = GitHub.plugin(throttling, paginateRest); +const octokit = new Octokit( + getOctokitOptions(token, { + throttle: { + onRateLimit: (retryAfter: number, options: OctokitOptions) => { + console.warn(`Request quota exhausted for request ${options.method} ${options.url}`); + + // Retry once after hitting a rate limit error, then give up + if (options.request.retryCount <= 1) { + console.log(`Retrying after ${retryAfter} seconds!`); + return true; + } + }, + onAbuseLimit: (retryAfter: number, options: OctokitOptions) => { + // does not retry, only logs a warning + console.warn(`Abuse detected for request ${options.method} ${options.url}`); + }, + }, + }), +); + +// Get output filepath +const outputFilepath = process.argv.at(4); +if (!outputFilepath) { + throw new Error('Error: must provide output file path'); +} + +// Get data from csv +const issues = fs + .readFileSync(inputFilepath) + .toString() + .split('\n') + .reduce((acc, issue) => { + if (!issue) { + return acc; + } + const issueNum = Number(issue.trim()); + if (!issueNum) { + return acc; + } + acc.push(issueNum); + return acc; + }, [] as number[]); + +const csvWriter = createObjectCsvWriter({ + path: outputFilepath, + header: [ + {id: 'number', title: 'number'}, + {id: 'title', title: 'title'}, + {id: 'labels', title: 'labels'}, + {id: 'type', title: 'type'}, + {id: 'capSWProjects', title: 'capSWProjects'}, + ], +}); + +function getIssueTypeFromLabels(labels: string[]): IssueType { + if (labels.includes('NewFeature')) { + return 'feature'; + } + if (labels.includes('Bug')) { + return 'bug'; + } + return 'other'; +} + +/** + * Returns a comma-delimited string with all projects associated with the given issue. + */ +async function getProjectsForIssue(issueNumber: number): Promise { + const response = await octokit.graphql( + ` + { + repository(owner: "Expensify", name: "App") { + issue(number: ${issueNumber}) { + projectsV2(last: 30) { + nodes { + title + } + } + } + } + } + `, + ); + return (response as {repository: {issue: {projectsV2: {nodes: Array<{title: string}>}}}}).repository.issue.projectsV2.nodes.map((node) => node.title).join(','); +} + +async function getGitHubData() { + const gitHubData = []; + // Note: we fetch issues in a loop rather than in parallel to help address rate limiting issues with a PAT + for (const issueNumber of issues) { + console.info(`Fetching ${issueNumber}`); + const result = await octokit.rest.issues + .get({ + owner: 'Expensify', + repo: 'App', + // eslint-disable-next-line @typescript-eslint/naming-convention + issue_number: issueNumber, + }) + .catch(() => { + console.warn(`Error getting issue ${issueNumber}`); + }); + if (result) { + const issue = result.data; + const labels = issue.labels.reduce((acc, label) => { + if (typeof label === 'string') { + acc.push(label); + } else if (label.name) { + acc.push(label.name); + } + return acc; + }, [] as string[]); + const type = getIssueTypeFromLabels(labels); + let capSWProjects = ''; + if (type === 'feature') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + capSWProjects = await getProjectsForIssue(issueNumber); + } + gitHubData.push({ + number: issue.number, + title: issue.title, + labels, + type, + capSWProjects, + }); + } + } + return gitHubData; +} + +getGitHubData() + .then((gitHubData) => csvWriter.writeRecords(gitHubData)) + .then(() => console.info(`Done ✅ Wrote file to ${outputFilepath}`)); diff --git a/src/CONST.ts b/src/CONST.ts index 4820bbb62b52..bbc92ddadbde 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1840,6 +1840,7 @@ const CONST = { DATE_OF_BIRTH: 1, ADDRESS: 2, PHONE_NUMBER: 3, + CONFIRM: 4, }, INDEX_LIST: ['1', '2', '3', '4'], }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index cb8bf2fdb5d3..fbc7465bc023 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -32,6 +32,7 @@ const ONYXKEYS = { /** Note: These are Persisted Requests - not all requests in the main queue as the key name might lead one to believe */ PERSISTED_REQUESTS: 'networkRequestQueue', + PERSISTED_ONGOING_REQUESTS: 'networkOngoingRequestQueue', /** Stores current date */ CURRENT_DATE: 'currentDate', @@ -422,6 +423,9 @@ const ONYXKEYS = { /** Stores the information about the saved searches */ SAVED_SEARCHES: 'nvp_savedSearches', + /** Stores the information about the recent searches */ + RECENT_SEARCHES: 'nvp_recentSearches', + /** Stores recently used currencies */ RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies', @@ -849,12 +853,14 @@ type OnyxValuesMapping = { // ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data [ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot; + [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; [ONYXKEYS.DEVICE_ID]: string; [ONYXKEYS.IS_SIDEBAR_LOADED]: boolean; [ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[]; + [ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: OnyxTypes.Request; [ONYXKEYS.CURRENT_DATE]: string; [ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials; [ONYXKEYS.STASHED_CREDENTIALS]: OnyxTypes.Credentials; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index dfcb42d3c4fe..9c429dd3e909 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -149,6 +149,7 @@ const ROUTES = { SETTINGS_ABOUT: 'settings/about', SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', + SETTINGS_WALLET_VERIFY_ACCOUNT: {route: 'settings/wallet/verify', getRoute: (backTo?: string) => getUrlWithBackToParam('settings/wallet/verify', backTo)}, SETTINGS_WALLET_DOMAINCARD: { route: 'settings/wallet/card/:cardID?', getRoute: (cardID: string) => `settings/wallet/card/${cardID}` as const, @@ -1274,10 +1275,7 @@ const ROUTES = { route: 'restricted-action/workspace/:policyID', getRoute: (policyID: string) => `restricted-action/workspace/${policyID}` as const, }, - MISSING_PERSONAL_DETAILS: { - route: 'missing-personal-details/workspace/:policyID', - getRoute: (policyID: string) => `missing-personal-details/workspace/${policyID}` as const, - }, + MISSING_PERSONAL_DETAILS: 'missing-personal-details', POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: { route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 395f1c4d5fb1..9a94d612dc80 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -112,6 +112,7 @@ const SCREENS = { CARD_ACTIVATE: 'Settings_Wallet_Card_Activate', REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address', + VERIFY_ACCOUNT: 'Settings_Wallet_Verify_Account', }, EXIT_SURVEY: { diff --git a/src/components/AddPaymentMethodMenu.tsx b/src/components/AddPaymentMethodMenu.tsx index 5621c031f959..0057438e3913 100644 --- a/src/components/AddPaymentMethodMenu.tsx +++ b/src/components/AddPaymentMethodMenu.tsx @@ -2,26 +2,22 @@ import type {RefObject} from 'react'; import React, {useEffect, useState} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; +import {completePaymentOnboarding} from '@libs/actions/IOU'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {AnchorPosition} from '@src/styles'; -import type {Report, Session} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import * as Expensicons from './Icon/Expensicons'; import type {PaymentMethod} from './KYCWall/types'; import type BaseModalProps from './Modal/types'; import PopoverMenu from './PopoverMenu'; -type AddPaymentMethodMenuOnyxProps = { - /** Session info for the currently logged-in user. */ - session: OnyxEntry; -}; - -type AddPaymentMethodMenuProps = AddPaymentMethodMenuOnyxProps & { +type AddPaymentMethodMenuProps = { /** Should the component be visible? */ isVisible: boolean; @@ -58,11 +54,11 @@ function AddPaymentMethodMenu({ anchorRef, iouReport, onItemSelected, - session, shouldShowPersonalBankAccountOption = false, }: AddPaymentMethodMenuProps) { const {translate} = useLocalize(); const [restoreFocusType, setRestoreFocusType] = useState(); + const [session] = useOnyx(ONYXKEYS.SESSION); // Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report // which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee. @@ -80,6 +76,7 @@ function AddPaymentMethodMenu({ return; } + completePaymentOnboarding(CONST.PAYMENT_SELECTED.PBA); onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); }, [isPersonalOnlyOption, isVisible, onItemSelected]); @@ -108,6 +105,7 @@ function AddPaymentMethodMenu({ text: translate('common.personalBankAccount'), icon: Expensicons.Bank, onSelected: () => { + completePaymentOnboarding(CONST.PAYMENT_SELECTED.PBA); onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); }, }, @@ -118,7 +116,10 @@ function AddPaymentMethodMenu({ { text: translate('common.businessBankAccount'), icon: Expensicons.Building, - onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT), + onSelected: () => { + completePaymentOnboarding(CONST.PAYMENT_SELECTED.BBA); + onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT); + }, }, ] : []), @@ -140,8 +141,4 @@ function AddPaymentMethodMenu({ AddPaymentMethodMenu.displayName = 'AddPaymentMethodMenu'; -export default withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, -})(AddPaymentMethodMenu); +export default AddPaymentMethodMenu; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 6ac0174da466..04607ef1cc7f 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -139,13 +139,24 @@ type ButtonProps = Partial & { /** Whether button's content should be centered */ isContentCentered?: boolean; + + /** Whether the Enter keyboard listening is active whether or not the screen that contains the button is focused */ + isPressOnEnterActive?: boolean; }; -type KeyboardShortcutComponentProps = Pick; +type KeyboardShortcutComponentProps = Pick; const accessibilityRoles: string[] = Object.values(CONST.ROLE); -function KeyboardShortcutComponent({isDisabled = false, isLoading = false, onPress = () => {}, pressOnEnter, allowBubble, enterKeyEventListenerPriority}: KeyboardShortcutComponentProps) { +function KeyboardShortcutComponent({ + isDisabled = false, + isLoading = false, + onPress = () => {}, + pressOnEnter, + allowBubble, + enterKeyEventListenerPriority, + isPressOnEnterActive = false, +}: KeyboardShortcutComponentProps) { const isFocused = useIsFocused(); const activeElementRole = useActiveElementRole(); @@ -163,7 +174,7 @@ function KeyboardShortcutComponent({isDisabled = false, isLoading = false, onPre const config = useMemo( () => ({ - isActive: pressOnEnter && !shouldDisableEnterShortcut && isFocused, + isActive: pressOnEnter && !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive), shouldBubble: allowBubble, priority: enterKeyEventListenerPriority, shouldPreventDefault: false, @@ -230,6 +241,7 @@ function Button( isSplitButton = false, link = false, isContentCentered = false, + isPressOnEnterActive, ...rest }: ButtonProps, ref: ForwardedRef, @@ -329,6 +341,7 @@ function Button( onPress={onPress} pressOnEnter={pressOnEnter} enterKeyEventListenerPriority={enterKeyEventListenerPriority} + isPressOnEnterActive={isPressOnEnterActive} /> )} diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index 9d6bd3a0a76a..e63b8bb91874 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -164,6 +164,7 @@ function ConfirmModal({ prompt={prompt} success={success} danger={danger} + isVisible={isVisible} shouldDisableConfirmButtonWhenOffline={shouldDisableConfirmButtonWhenOffline} shouldShowCancelButton={shouldShowCancelButton} shouldCenterContent={shouldCenterContent} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 12da12b8b15d..fdf6f8edd825 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -113,10 +113,15 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const isArchivedReport = ReportUtils.isArchivedRoomWithID(moneyRequestReport?.reportID); const [archiveReason] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${moneyRequestReport?.reportID ?? '-1'}`, {selector: ReportUtils.getArchiveReason}); - const shouldShowPayButton = useMemo( - () => IOU.canIOUBePaid(moneyRequestReport, chatReport, policy, transaction ? [transaction] : undefined), + const getCanIOUBePaid = useCallback( + (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(moneyRequestReport, chatReport, policy, transaction ? [transaction] : undefined, onlyShowPayElsewhere), [moneyRequestReport, chatReport, policy, transaction], ); + const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); + + const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); + + const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, policy), [moneyRequestReport, policy]); @@ -292,6 +297,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea {shouldShowSettlementButton && !shouldUseNarrowLayout && ( {shouldShowSettlementButton && shouldUseNarrowLayout && ( ; - - policyID: string | undefined; }; -function IssueCardMessage({action, policyID}: IssueCardMessageProps) { +function IssueCardMessage({action}: IssueCardMessageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const [session] = useOnyx(ONYXKEYS.SESSION); - const assigneeAccountID = (action?.originalMessage as IssueNewCardOriginalMessage)?.assigneeAccountID; + const assigneeAccountID = (ReportActionsUtils.getOriginalMessage(action) as IssueNewCardOriginalMessage)?.assigneeAccountID; const missingDetails = !privatePersonalDetails?.legalFirstName || @@ -45,12 +43,7 @@ function IssueCardMessage({action, policyID}: IssueCardMessageProps) { ${ReportActionsUtils.getCardIssuedMessage(action, true)}`} /> {shouldShowAddMissingDetailsButton && ( - )} - {/** + + {isHidden ? translate('moderation.revealMessage') : translate('moderation.hideMessage')} + + + )} + {/** These are the actionable buttons that appear at the bottom of a Concierge message for example: Invite a user mentioned but not a member of the room https://github.com/Expensify/App/issues/32741 */} - {actionableItemButtons.length > 0 && ( - - )} - - ) : ( - - )} - - + {actionableItemButtons.length > 0 && ( + + )} + + ) : ( + + )} + + + ); } const numberOfThreadReplies = action.childVisibleActionCount ?? 0; diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 7449042141f3..e8f02f0c1975 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -76,7 +76,7 @@ function IOURequestStepParticipants({ }, [iouType, translate, isSplitRequest, action]); const selfDMReportID = useMemo(() => ReportUtils.findSelfDMReportID(), []); - const shouldDisplayTrackExpenseButton = !!selfDMReportID; + const shouldDisplayTrackExpenseButton = !!selfDMReportID && action === CONST.IOU.ACTION.CREATE; const receiptFilename = transaction?.filename; const receiptPath = transaction?.receipt?.source; diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index 491c37c9a402..0ddddf7ff878 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -2,8 +2,8 @@ import type {RouteProp} from '@react-navigation/native'; import {useIsFocused} from '@react-navigation/native'; import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {forwardRef} from 'react'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import * as IOUUtils from '@libs/IOUUtils'; @@ -38,14 +38,25 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM | typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO; -type Route = RouteProp; +type Route = RouteProp; -type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {route: Route}; +type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & { + route: Route; +}; -export default function , TRef>(WrappedComponent: ComponentType>) { +export default function , TRef>( + WrappedComponent: ComponentType>, +): React.ComponentType & RefAttributes> { // eslint-disable-next-line rulesdir/no-negated-variables - function WithFullTransactionOrNotFound(props: TProps, ref: ForwardedRef) { - const transactionID = props.transaction?.transactionID; + function WithFullTransactionOrNotFound(props: Omit, ref: ForwardedRef) { + const {route} = props; + const transactionID = route.params.transactionID ?? -1; + const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE; + + const shouldUseTransactionDraft = IOUUtils.shouldUseTransactionDraft(userAction); + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const [transactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); const isFocused = useIsFocused(); @@ -59,7 +70,8 @@ export default function ); @@ -67,19 +79,7 @@ export default function , WithFullTransactionOrNotFoundOnyxProps>({ - transaction: { - key: ({route}) => { - const transactionID = route.params.transactionID ?? -1; - const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE; - - if (IOUUtils.shouldUseTransactionDraft(userAction)) { - return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}` as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`; - } - return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - }, - }, - })(forwardRef(WithFullTransactionOrNotFound)); + return forwardRef(WithFullTransactionOrNotFound); } export type {WithFullTransactionOrNotFoundProps}; diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx index 26c2a9092131..c9858738906d 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx @@ -1,7 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; import FormProvider from '@components/Form/FormProvider'; @@ -15,9 +14,8 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -30,21 +28,16 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/SettingsStatusSetForm'; -import type {CustomStatusDraft} from '@src/types/onyx'; - -type StatusPageOnyxProps = { - draftStatus: OnyxEntry; -}; - -type StatusPageProps = StatusPageOnyxProps & WithCurrentUserPersonalDetailsProps; const initialEmoji = '💬'; -function StatusPage({draftStatus, currentUserPersonalDetails}: StatusPageProps) { +function StatusPage() { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const [draftStatus] = useOnyx(ONYXKEYS.CUSTOM_STATUS_DRAFT); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const formRef = useRef(null); const [brickRoadIndicator, setBrickRoadIndicator] = useState>(); const currentUserEmojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; @@ -97,6 +90,9 @@ function StatusPage({draftStatus, currentUserPersonalDetails}: StatusPageProps) const navigateBackToPreviousScreen = useCallback(() => Navigation.goBack(), []); const updateStatus = useCallback( ({emojiCode, statusText}: FormOnyxValues) => { + if (navigateBackToPreviousScreenTask.current) { + return; + } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const clearAfterTime = draftClearAfter || currentUserClearAfter || CONST.CUSTOM_STATUS_TYPES.NEVER; const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({dateTimeString: clearAfterTime}); @@ -118,6 +114,9 @@ function StatusPage({draftStatus, currentUserPersonalDetails}: StatusPageProps) ); const clearStatus = () => { + if (navigateBackToPreviousScreenTask.current) { + return; + } User.clearCustomStatus(); User.updateDraftCustomStatus({ text: '', @@ -229,10 +228,4 @@ function StatusPage({draftStatus, currentUserPersonalDetails}: StatusPageProps) StatusPage.displayName = 'StatusPage'; -export default withCurrentUserPersonalDetails( - withOnyx({ - draftStatus: { - key: () => ONYXKEYS.CUSTOM_STATUS_DRAFT, - }, - })(StatusPage), -); +export default StatusPage; diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 97de309453d5..46f6ded27cbd 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -186,6 +186,7 @@ function PaymentMethodList({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); const [bankAccountList = {}] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); @@ -319,6 +320,14 @@ function PaymentMethodList({ */ const renderListEmptyComponent = () => {translate('paymentMethodList.addFirstPaymentMethod')}; + const onPressItem = useCallback(() => { + if (!isUserValidated) { + Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT.getRoute(ROUTES.SETTINGS_ADD_BANK_ACCOUNT)); + return; + } + onPress(); + }, [isUserValidated, onPress]); + const renderListFooterComponent = useCallback( () => shouldShowAddBankAccountButton ? ( @@ -333,16 +342,15 @@ function PaymentMethodList({ /> ) : ( ), - [shouldShowAddBankAccountButton, translate, onPress, buttonRef, styles.paymentMethod, listItemStyle, isUserValidated], + [shouldShowAddBankAccountButton, onPressItem, translate, onPress, buttonRef, styles.paymentMethod, listItemStyle, isUserValidated], ); /** diff --git a/src/pages/settings/Wallet/VerifyAccountPage.tsx b/src/pages/settings/Wallet/VerifyAccountPage.tsx new file mode 100644 index 000000000000..1fb39304ec41 --- /dev/null +++ b/src/pages/settings/Wallet/VerifyAccountPage.tsx @@ -0,0 +1,86 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useEffect, useRef} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import ValidateCodeForm from '@components/ValidateCodeActionModal/ValidateCodeForm'; +import useLocalize from '@hooks/useLocalize'; +import useSafePaddingBottomStyle from '@hooks/useSafePaddingBottomStyle'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as User from '@userActions/User'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type VerifyAccountPageProps = StackScreenProps; + +function VerifyAccountPage({route}: VerifyAccountPageProps) { + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + const contactMethod = account?.primaryLogin ?? ''; + const themeStyles = useThemeStyles(); + const {translate} = useLocalize(); + const safePaddingBottomStyle = useSafePaddingBottomStyle(); + const loginInputRef = useRef(null); + const loginData = loginList?.[contactMethod]; + const styles = useThemeStyles(); + const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); + const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); + + const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE); + + const navigateBackTo = route?.params?.backTo ?? ROUTES.SETTINGS_WALLET; + + useEffect(() => { + User.requestValidateCodeAction(); + return () => User.clearUnvalidatedNewContactMethodAction(); + }, []); + + const handleSubmitForm = useCallback( + (submitCode: string) => { + User.validateSecondaryLogin(loginList, contactMethod ?? '', submitCode); + }, + [loginList, contactMethod], + ); + + useEffect(() => { + if (!isUserValidated) { + return; + } + Navigation.navigate(navigateBackTo); + }, [isUserValidated, navigateBackTo]); + + return ( + loginInputRef.current?.focus()} + includeSafeAreaPaddingBottom={false} + shouldEnableMaxHeight + testID={VerifyAccountPage.displayName} + > + Navigation.goBack(ROUTES.SETTINGS_WALLET)} + /> + + {translate('contacts.featureRequiresValidate')} + User.clearContactMethodErrors(contactMethod, 'validateLogin')} + buttonStyles={[styles.justifyContentEnd, styles.flex1, safePaddingBottomStyle]} + /> + + + ); +} + +VerifyAccountPage.displayName = 'VerifyAccountPage'; + +export default VerifyAccountPage; diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx index 4118d4e267a8..7b9366370349 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx @@ -57,6 +57,7 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { const [isLoadingPaymentMethods] = useOnyx(ONYXKEYS.IS_LOADING_PAYMENT_METHODS, {initialValue: true}); const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET); const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS, {initialValue: {}}); + const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); const theme = useTheme(); const styles = useThemeStyles(); @@ -497,7 +498,13 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { return ( } - onPress={() => Navigation.navigate(ROUTES.SETTINGS_ENABLE_PAYMENTS)} + onPress={() => { + if (!isUserValidated) { + Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT.getRoute(ROUTES.SETTINGS_ENABLE_PAYMENTS)); + return; + } + Navigation.navigate(ROUTES.SETTINGS_ENABLE_PAYMENTS); + }} disabled={network.isOffline} title={translate('walletPage.enableWallet')} icon={Expensicons.Wallet} diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index 1a6f59830506..5deae769531d 100644 --- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx @@ -1,7 +1,7 @@ /* eslint-disable rulesdir/no-negated-variables */ import React, {useEffect, useState} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -43,7 +43,7 @@ const ACCESS_VARIANTS = { >; type AccessVariant = keyof typeof ACCESS_VARIANTS; -type AccessOrNotFoundWrapperOnyxProps = { +type AccessOrNotFoundWrapperChildrenProps = { /** The report that holds the transaction */ report: OnyxEntry; @@ -54,9 +54,9 @@ type AccessOrNotFoundWrapperOnyxProps = { isLoadingReportData: OnyxEntry; }; -type AccessOrNotFoundWrapperProps = AccessOrNotFoundWrapperOnyxProps & { +type AccessOrNotFoundWrapperProps = { /** The children to render */ - children: ((props: AccessOrNotFoundWrapperOnyxProps) => React.ReactNode) | React.ReactNode; + children: ((props: AccessOrNotFoundWrapperChildrenProps) => React.ReactNode) | React.ReactNode; /** The id of the report that holds the transaction */ reportID?: string; @@ -102,13 +102,25 @@ function PageNotFoundFallback({policyID, shouldShowFullScreenFallback, fullPageN ); } -function AccessOrNotFoundWrapper({accessVariants = [], fullPageNotFoundViewProps, shouldBeBlocked, ...props}: AccessOrNotFoundWrapperProps) { - const {policy, policyID, report, iouType, allPolicies, featureName, isLoadingReportData} = props; +function AccessOrNotFoundWrapper({ + accessVariants = [], + fullPageNotFoundViewProps, + shouldBeBlocked, + policyID, + reportID, + iouType, + allPolicies, + featureName, + ...props +}: AccessOrNotFoundWrapperProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); const {login = ''} = useCurrentUserPersonalDetails(); const isPolicyIDInRoute = !!policyID?.length; const isMoneyRequest = !!iouType && IOUUtils.isValidMoneyRequestType(iouType); const isFromGlobalCreate = isEmptyObject(report?.reportID); - const pendingField = featureName ? props.policy?.pendingFields?.[featureName] : undefined; + const pendingField = featureName ? policy?.pendingFields?.[featureName] : undefined; useEffect(() => { if (!isPolicyIDInRoute || !isEmptyObject(policy)) { @@ -160,19 +172,9 @@ function AccessOrNotFoundWrapper({accessVariants = [], fullPageNotFoundViewProps ); } - return callOrReturn(props.children, props); + return callOrReturn(props.children, {report, policy, isLoadingReportData}); } export type {AccessVariant}; -export default withOnyx({ - report: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - }, - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, - isLoadingReportData: { - key: ONYXKEYS.IS_LOADING_REPORT_DATA, - }, -})(AccessOrNotFoundWrapper); +export default AccessOrNotFoundWrapper; diff --git a/src/styles/index.ts b/src/styles/index.ts index 33b39ac5fa82..d602384cdecf 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3622,10 +3622,11 @@ const styles = (theme: ThemeColors) => lineHeight: 16, }, - searchRouterInput: { + searchRouterTextInputContainer: { borderRadius: variables.componentBorderRadiusSmall, - borderWidth: 2, - borderColor: theme.borderFocus, + borderWidth: 1, + borderBottomWidth: 1, + paddingHorizontal: 8, }, searchRouterInputResults: { @@ -4153,11 +4154,6 @@ const styles = (theme: ThemeColors) => width: 1, }, - taskCheckboxWrapper: { - height: variables.fontSizeNormalHeight, - ...flex.justifyContentCenter, - }, - taskTitleMenuItem: { ...writingDirection.ltr, ...headlineFont, @@ -4702,6 +4698,14 @@ const styles = (theme: ThemeColors) => borderRadius: 8, }, + searchQueryListItemStyle: { + alignItems: 'center', + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 12, + borderRadius: 8, + }, + selectionListStickyHeader: { backgroundColor: theme.appBG, }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index c7665fc1e3dd..9eeced6de5a6 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1667,6 +1667,17 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ alignItems: 'center', justifyContent: 'center', }), + + getTaskPreviewIconWrapper: (avatarSize?: AvatarSizeName) => ({ + height: avatarSize ? getAvatarSize(avatarSize) : variables.fontSizeNormalHeight, + ...styles.justifyContentCenter, + }), + + getTaskPreviewTitleStyle: (iconHeight: number, isTaskCompleted: boolean): StyleProp => [ + styles.flex1, + isTaskCompleted ? [styles.textSupporting, styles.textLineThrough] : [], + {marginTop: (iconHeight - variables.fontSizeNormalHeight) / 2}, + ], }); type StyleUtilsType = ReturnType; diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index f4be70391eb5..ed4651bcf2e0 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -37,6 +37,10 @@ export default { maxHeight: '100%', }, + mh85vh: { + maxHeight: '85vh', + }, + mnh100: { minHeight: '100%', }, diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 993a6dba1628..c2008d8a68f0 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -15,6 +15,10 @@ export default { margin: 8, }, + m3: { + margin: 12, + }, + m4: { margin: 16, }, diff --git a/src/types/onyx/RecentSearch.ts b/src/types/onyx/RecentSearch.ts new file mode 100644 index 000000000000..738d57956e5c --- /dev/null +++ b/src/types/onyx/RecentSearch.ts @@ -0,0 +1,13 @@ +/** + * Model of a single recent search + */ +type RecentSearchItem = { + /** Query string for the recent search */ + query: string; + + /** Timestamp of recent search */ + timestamp: string; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {RecentSearchItem}; diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts index 1f3f788a8418..238e3a8c6a81 100644 --- a/src/types/onyx/Request.ts +++ b/src/types/onyx/Request.ts @@ -55,8 +55,70 @@ type RequestData = { shouldSkipWebProxy?: boolean; }; +/** + * Model of a conflict request that has to be replaced in the request queue. + */ +type ConflictRequestReplace = { + /** + * The action to take in case of a conflict. + */ + type: 'replace'; + + /** + * The index of the request in the queue to update. + */ + index: number; +}; + +/** + * Model of a conflict request that has to be enqueued at the end of request queue. + */ +type ConflictRequestPush = { + /** + * The action to take in case of a conflict. + */ + type: 'push'; +}; + +/** + * Model of a conflict request that does not need to be updated or saved in the request queue. + */ +type ConflictRequestNoAction = { + /** + * The action to take in case of a conflict. + */ + type: 'noAction'; +}; + +/** + * An object that has the request and the action to take in case of a conflict. + */ +type ConflictActionData = { + /** + * The action to take in case of a conflict. + */ + conflictAction: ConflictRequestReplace | ConflictRequestPush | ConflictRequestNoAction; +}; + +/** + * An object that describes how a new write request can identify any queued requests that may conflict with or be undone by the new request, + * and how to resolve those conflicts. + */ +type RequestConflictResolver = { + /** + * A function that checks if a new request conflicts with any existing requests in the queue. + */ + checkAndFixConflictingRequest?: (persistedRequest: Request[]) => ConflictActionData; + + /** + * A boolean flag to mark a request as persisting into Onyx, if set to true it means when Onyx loads + * the ongoing request, it will be removed from the persisted request queue. + */ + persistWhenOngoing?: boolean; +}; + /** Model of requests sent to the API */ -type Request = RequestData & OnyxData; +type Request = RequestData & OnyxData & RequestConflictResolver; /** * An object used to describe how a request can be paginated. @@ -85,4 +147,4 @@ type PaginatedRequest = Request & }; export default Request; -export type {OnyxData, RequestType, PaginationConfig, PaginatedRequest}; +export type {OnyxData, RequestType, PaginationConfig, PaginatedRequest, RequestConflictResolver, ConflictActionData}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 420ccf884f91..abad91f78c77 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -64,6 +64,7 @@ import type QuickAction from './QuickAction'; import type RecentlyUsedCategories from './RecentlyUsedCategories'; import type RecentlyUsedReportFields from './RecentlyUsedReportFields'; import type RecentlyUsedTags from './RecentlyUsedTags'; +import type {RecentSearchItem} from './RecentSearch'; import type RecentWaypoint from './RecentWaypoint'; import type ReimbursementAccount from './ReimbursementAccount'; import type Report from './Report'; @@ -230,6 +231,7 @@ export type { WorkspaceTooltip, CardFeeds, SaveSearch, + RecentSearchItem, ImportedSpreadsheet, ValidateMagicCodeAction, }; diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index 62d6a54b20b5..51dc775da359 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -3,10 +3,12 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import * as App from '@libs/actions/App'; import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; +import {WRITE_COMMANDS} from '@libs/API/types'; import HttpUtils from '@libs/HttpUtils'; import PushNotification from '@libs/Notification/PushNotification'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection import '@libs/Notification/PushNotification/subscribePushNotification'; +import * as PersistedRequests from '@userActions/PersistedRequests'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Credentials, Session} from '@src/types/onyx'; @@ -28,7 +30,7 @@ OnyxUpdateManager(); beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); describe('Session', () => { - test('Authenticate is called with saved credentials when a session expires', () => { + test('Authenticate is called with saved credentials when a session expires', async () => { // Given a test user and set of authToken with subscriptions to session and credentials const TEST_USER_LOGIN = 'test@testguy.com'; const TEST_USER_ACCOUNT_ID = 1; @@ -48,61 +50,100 @@ describe('Session', () => { }); // When we sign in with the test user - return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, 'Password1', TEST_INITIAL_AUTH_TOKEN) - .then(waitForBatchedUpdates) - .then(() => { - // Then our re-authentication credentials should be generated and our session data - // have the correct information + initial authToken. - expect(credentials?.login).toBe(TEST_USER_LOGIN); - expect(credentials?.autoGeneratedLogin).not.toBeUndefined(); - expect(credentials?.autoGeneratedPassword).not.toBeUndefined(); - expect(session?.authToken).toBe(TEST_INITIAL_AUTH_TOKEN); - expect(session?.accountID).toBe(TEST_USER_ACCOUNT_ID); - expect(session?.email).toBe(TEST_USER_LOGIN); - - // At this point we have an authToken. To simulate it expiring we'll just make another - // request and mock the response so it returns 407. Once this happens we should attempt - // to Re-Authenticate with the stored credentials. Our next call will be to Authenticate - // so we will mock that response with a new authToken and then verify that Onyx has our - // data. - (HttpUtils.xhr as jest.MockedFunction) - - // This will make the call to OpenApp below return with an expired session code - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, - }), - ) - - // The next call should be Authenticate since we are reauthenticating - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.SUCCESS, - accountID: TEST_USER_ACCOUNT_ID, - authToken: TEST_REFRESHED_AUTH_TOKEN, - email: TEST_USER_LOGIN, - }), - ); - - // When we attempt to fetch the initial app data via the API - App.confirmReadyToOpenApp(); - App.openApp(); - return waitForBatchedUpdates(); - }) - .then(() => { - // Then it should fail and reauthenticate the user adding the new authToken to the session - // data in Onyx - expect(session?.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN); - }); + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, 'Password1', TEST_INITIAL_AUTH_TOKEN); + await waitForBatchedUpdates(); + + // Then our re-authentication credentials should be generated and our session data + // have the correct information + initial authToken. + expect(credentials?.login).toBe(TEST_USER_LOGIN); + expect(credentials?.autoGeneratedLogin).not.toBeUndefined(); + expect(credentials?.autoGeneratedPassword).not.toBeUndefined(); + expect(session?.authToken).toBe(TEST_INITIAL_AUTH_TOKEN); + expect(session?.accountID).toBe(TEST_USER_ACCOUNT_ID); + expect(session?.email).toBe(TEST_USER_LOGIN); + + // At this point we have an authToken. To simulate it expiring we'll just make another + // request and mock the response so it returns 407. Once this happens we should attempt + // to Re-Authenticate with the stored credentials. Our next call will be to Authenticate + // so we will mock that response with a new authToken and then verify that Onyx has our + // data. + (HttpUtils.xhr as jest.MockedFunction) + + // This will make the call to OpenApp below return with an expired session code + .mockImplementationOnce(() => + Promise.resolve({ + jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, + }), + ) + + // The next call should be Authenticate since we are reauthenticating + .mockImplementationOnce(() => + Promise.resolve({ + jsonCode: CONST.JSON_CODE.SUCCESS, + accountID: TEST_USER_ACCOUNT_ID, + authToken: TEST_REFRESHED_AUTH_TOKEN, + email: TEST_USER_LOGIN, + }), + ); + + // When we attempt to fetch the initial app data via the API + App.confirmReadyToOpenApp(); + App.openApp(); + await waitForBatchedUpdates(); + + // Then it should fail and reauthenticate the user adding the new authToken to the session + // data in Onyx + expect(session?.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN); }); - test('Push notifications are subscribed after signing in', () => - TestHelper.signInWithTestUser() - .then(waitForBatchedUpdates) - .then(() => expect(PushNotification.register).toBeCalled())); + test('Push notifications are subscribed after signing in', async () => { + await TestHelper.signInWithTestUser(); + await waitForBatchedUpdates(); + expect(PushNotification.register).toBeCalled(); + }); + + test('Push notifications are unsubscribed after signing out', async () => { + await TestHelper.signInWithTestUser(); + await TestHelper.signOutTestUser(); + expect(PushNotification.deregister).toBeCalled(); + }); + + test('ReconnectApp should push request to the queue', async () => { + await TestHelper.signInWithTestUser(); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + App.confirmReadyToOpenApp(); + App.reconnectApp(); + + await waitForBatchedUpdates(); - test('Push notifications are unsubscribed after signing out', () => - TestHelper.signInWithTestUser() - .then(TestHelper.signOutTestUser) - .then(() => expect(PushNotification.deregister).toBeCalled())); + expect(PersistedRequests.getAll().length).toBe(1); + expect(PersistedRequests.getAll().at(0)?.command).toBe(WRITE_COMMANDS.RECONNECT_APP); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + await waitForBatchedUpdates(); + + expect(PersistedRequests.getAll().length).toBe(0); + }); + + test('ReconnectApp should replace same requests from the queue', async () => { + await TestHelper.signInWithTestUser(); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + App.confirmReadyToOpenApp(); + App.reconnectApp(); + App.reconnectApp(); + App.reconnectApp(); + App.reconnectApp(); + + await waitForBatchedUpdates(); + + expect(PersistedRequests.getAll().length).toBe(1); + expect(PersistedRequests.getAll().at(0)?.command).toBe(WRITE_COMMANDS.RECONNECT_APP); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + expect(PersistedRequests.getAll().length).toBe(0); + }); }); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 1f590a474ad5..a47d9d8e8631 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -41,18 +41,16 @@ npm run android 3. We need to modify the app entry to point to the one for the tests. Therefore rename `./index.js` to `./appIndex.js` temporarily. -4. Create a new `./index.js` with the following content: -```js -require('./src/libs/E2E/reactNativeLaunchingTest'); -``` - -5. In `./src/libs/E2E/reactNativeLaunchingTest.ts` change the main app import to the new `./appIndex.js` file: -```diff -- import '../../../index'; -+ import '../../../appIndex'; -``` - -6. You can now run the tests. This command will invoke the test runner: +4. Temporarily add to the `package.json` a `main` field pointing to the e2e entry file: + + ```diff + { + "private": true, ++ "main": "src/libs/E2E/reactNativeEntry.ts" + } + ``` + +5. You can now run the tests. This command will invoke the test runner: ```sh npm run test:e2e:dev diff --git a/tests/unit/APITest.ts b/tests/unit/APITest.ts index ff851b52b8f1..14c4cadcb26d 100644 --- a/tests/unit/APITest.ts +++ b/tests/unit/APITest.ts @@ -43,6 +43,7 @@ const originalXHR = HttpUtils.xhr; beforeEach(() => { global.fetch = TestHelper.getGlobalFetchMock(); HttpUtils.xhr = originalXHR; + MainQueue.clear(); HttpUtils.cancelPendingRequests(); PersistedRequests.clear(); @@ -170,36 +171,38 @@ describe('APITests', () => { .then(waitForBatchedUpdates) .then(() => { // Then requests should remain persisted until the xhr call is resolved - expect(PersistedRequests.getAll().length).toEqual(2); + expect(PersistedRequests.getAll().length).toEqual(1); xhrCalls.at(0)?.resolve({jsonCode: CONST.JSON_CODE.SUCCESS}); return waitForBatchedUpdates(); }) .then(waitForBatchedUpdates) .then(() => { - expect(PersistedRequests.getAll().length).toEqual(1); - expect(PersistedRequests.getAll()).toEqual([expect.objectContaining({command: 'mock command', data: expect.objectContaining({param2: 'value2'})})]); + expect(PersistedRequests.getAll().length).toEqual(0); + expect(PersistedRequests.getOngoingRequest()).toEqual(expect.objectContaining({command: 'mock command', data: expect.objectContaining({param2: 'value2'})})); // When a request fails it should be retried xhrCalls.at(1)?.reject(new Error(CONST.ERROR.FAILED_TO_FETCH)); return waitForBatchedUpdates(); }) .then(() => { + // The ongoingRequest it is moving back to the persistedRequests queue expect(PersistedRequests.getAll().length).toEqual(1); expect(PersistedRequests.getAll()).toEqual([expect.objectContaining({command: 'mock command', data: expect.objectContaining({param2: 'value2'})})]); - // We need to advance past the request throttle back off timer because the request won't be retried until then return new Promise((resolve) => { setTimeout(resolve, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS); }).then(waitForBatchedUpdates); }) .then(() => { + // A new promise is created after the back off timer // Finally, after it succeeds the queue should be empty xhrCalls.at(2)?.resolve({jsonCode: CONST.JSON_CODE.SUCCESS}); return waitForBatchedUpdates(); }) .then(() => { expect(PersistedRequests.getAll().length).toEqual(0); + expect(PersistedRequests.getOngoingRequest()).toBeNull(); }) ); }); @@ -555,7 +558,6 @@ describe('APITests', () => { // THEN the queue should be stopped and there should be no more requests to run expect(SequentialQueue.isRunning()).toBe(false); expect(PersistedRequests.getAll().length).toBe(0); - // And our Write request should run before our non persistable one in a blocking way const firstRequest = xhr.mock.calls.at(0); const [firstRequestCommandName] = firstRequest ?? []; diff --git a/tests/unit/PersistedRequests.ts b/tests/unit/PersistedRequests.ts index 670625f65f97..7d3a7288ed90 100644 --- a/tests/unit/PersistedRequests.ts +++ b/tests/unit/PersistedRequests.ts @@ -1,5 +1,9 @@ +import Onyx from 'react-native-onyx'; import * as PersistedRequests from '../../src/libs/actions/PersistedRequests'; +import ONYXKEYS from '../../src/ONYXKEYS'; import type Request from '../../src/types/onyx/Request'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; const request: Request = { command: 'OpenReport', @@ -7,13 +11,22 @@ const request: Request = { failureData: [{key: 'reportMetadata_2', onyxMethod: 'merge', value: {}}], }; +beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }), +); + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); PersistedRequests.clear(); PersistedRequests.save(request); }); afterEach(() => { PersistedRequests.clear(); + Onyx.clear(); }); describe('PersistedRequests', () => { @@ -26,4 +39,53 @@ describe('PersistedRequests', () => { PersistedRequests.remove(request); expect(PersistedRequests.getAll().length).toBe(0); }); + + it('when process the next request, queue should be empty', () => { + const nextRequest = PersistedRequests.processNextRequest(); + expect(PersistedRequests.getAll().length).toBe(0); + expect(nextRequest).toEqual(request); + }); + + it('when onyx persist the request, it should remove from the list the ongoing request', () => { + expect(PersistedRequests.getAll().length).toBe(1); + const request2: Request = { + command: 'AddComment', + successData: [{key: 'reportMetadata_3', onyxMethod: 'merge', value: {}}], + failureData: [{key: 'reportMetadata_4', onyxMethod: 'merge', value: {}}], + }; + PersistedRequests.save(request2); + PersistedRequests.processNextRequest(); + return waitForBatchedUpdates().then(() => { + expect(PersistedRequests.getAll().length).toBe(1); + expect(PersistedRequests.getAll().at(0)).toEqual(request2); + }); + }); + + it('update the request at the given index with new data', () => { + const newRequest: Request = { + command: 'OpenReport', + successData: [{key: 'reportMetadata_1', onyxMethod: 'set', value: {}}], + failureData: [{key: 'reportMetadata_2', onyxMethod: 'set', value: {}}], + }; + PersistedRequests.update(0, newRequest); + expect(PersistedRequests.getAll().at(0)).toEqual(newRequest); + }); + + it('update the ongoing request with new data', () => { + const newRequest: Request = { + command: 'OpenReport', + successData: [{key: 'reportMetadata_1', onyxMethod: 'set', value: {}}], + failureData: [{key: 'reportMetadata_2', onyxMethod: 'set', value: {}}], + }; + PersistedRequests.updateOngoingRequest(newRequest); + expect(PersistedRequests.getOngoingRequest()).toEqual(newRequest); + }); + + it('when removing a request should update the persistedRequests queue and clear the ongoing request', () => { + PersistedRequests.processNextRequest(); + expect(PersistedRequests.getOngoingRequest()).toEqual(request); + PersistedRequests.remove(request); + expect(PersistedRequests.getOngoingRequest()).toBeNull(); + expect(PersistedRequests.getAll().length).toBe(0); + }); }); diff --git a/tests/unit/SequentialQueueTest.ts b/tests/unit/SequentialQueueTest.ts new file mode 100644 index 000000000000..4b5c026eb8f4 --- /dev/null +++ b/tests/unit/SequentialQueueTest.ts @@ -0,0 +1,260 @@ +import Onyx from 'react-native-onyx'; +import {waitForActiveRequestsToBeEmpty} from '@libs/E2E/utils/NetworkInterceptor'; +import * as PersistedRequests from '@userActions/PersistedRequests'; +import ONYXKEYS from '@src/ONYXKEYS'; +import * as SequentialQueue from '../../src/libs/Network/SequentialQueue'; +import type Request from '../../src/types/onyx/Request'; +import type {ConflictActionData} from '../../src/types/onyx/Request'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const request: Request = { + command: 'ReconnectApp', + successData: [{key: 'userMetadata', onyxMethod: 'set', value: {accountID: 1234}}], + failureData: [{key: 'userMetadata', onyxMethod: 'set', value: {}}], +}; + +describe('SequentialQueue', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + beforeEach(() => { + global.fetch = TestHelper.getGlobalFetchMock(); + return Onyx.clear().then(waitForBatchedUpdates); + }); + + it('should push one request and persist one', () => { + SequentialQueue.push(request); + expect(PersistedRequests.getLength()).toBe(1); + }); + + it('should push two requests and persist two', () => { + SequentialQueue.push(request); + SequentialQueue.push(request); + expect(PersistedRequests.getLength()).toBe(2); + }); + + it('should push two requests with conflict resolution and replace', () => { + SequentialQueue.push(request); + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: (persistedRequests) => { + // should be one instance of ReconnectApp, get the index to replace it later + const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); + if (index === -1) { + return {conflictAction: {type: 'push'}}; + } + + return { + conflictAction: {type: 'replace', index}, + }; + }, + }; + SequentialQueue.push(requestWithConflictResolution); + expect(PersistedRequests.getLength()).toBe(1); + // We know there is only one request in the queue, so we can get the first one and verify + // that the persisted request is the second one. + const persistedRequest = PersistedRequests.getAll().at(0); + expect(persistedRequest?.data?.accountID).toBe(56789); + }); + + it('should push two requests with conflict resolution and push', () => { + SequentialQueue.push(request); + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: () => { + return {conflictAction: {type: 'push'}}; + }, + }; + SequentialQueue.push(requestWithConflictResolution); + expect(PersistedRequests.getLength()).toBe(2); + }); + + it('should push two requests with conflict resolution and noAction', () => { + SequentialQueue.push(request); + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: () => { + return {conflictAction: {type: 'noAction'}}; + }, + }; + SequentialQueue.push(requestWithConflictResolution); + expect(PersistedRequests.getLength()).toBe(1); + }); + + it('should add a new request even if a similar one is ongoing', async () => { + // .push at the end flush the queue + SequentialQueue.push(request); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: (persistedRequests) => { + // should be one instance of ReconnectApp, get the index to replace it later + const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); + if (index === -1) { + return {conflictAction: {type: 'push'}}; + } + + return { + conflictAction: {type: 'replace', index}, + }; + }, + }; + + SequentialQueue.push(requestWithConflictResolution); + expect(PersistedRequests.getLength()).toBe(2); + }); + + it('should replace request request in queue while a similar one is ongoing', async () => { + // .push at the end flush the queue + SequentialQueue.push(request); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + const conflictResolver = (persistedRequests: Request[]): ConflictActionData => { + // should be one instance of ReconnectApp, get the index to replace it later + const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); + if (index === -1) { + return {conflictAction: {type: 'push'}}; + } + + return { + conflictAction: {type: 'replace', index}, + }; + }; + + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: conflictResolver, + }; + + const requestWithConflictResolution2: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: conflictResolver, + }; + + SequentialQueue.push(requestWithConflictResolution); + SequentialQueue.push(requestWithConflictResolution2); + + expect(PersistedRequests.getLength()).toBe(2); + }); + + it('should replace request request in queue while a similar one is ongoing and keep the same index', () => { + SequentialQueue.push({command: 'OpenReport'}); + SequentialQueue.push(request); + + const requestWithConflictResolution: Request = { + command: 'ReconnectApp', + data: {accountID: 56789}, + checkAndFixConflictingRequest: (persistedRequests) => { + // should be one instance of ReconnectApp, get the index to replace it later + const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); + if (index === -1) { + return {conflictAction: {type: 'push'}}; + } + + return { + conflictAction: {type: 'replace', index}, + }; + }, + }; + + SequentialQueue.push(requestWithConflictResolution); + SequentialQueue.push({command: 'AddComment'}); + SequentialQueue.push({command: 'OpenReport'}); + + expect(PersistedRequests.getLength()).toBe(4); + const persistedRequests = PersistedRequests.getAll(); + // We know ReconnectApp is at index 1 in the queue, so we can get it to verify + // that was replaced by the new request. + expect(persistedRequests.at(1)?.data?.accountID).toBe(56789); + }); + + // need to test a rance condition between processing the next request and then pushing a new request with conflict resolver + it('should resolve the conflict and replace the correct request in the queue while a new request is picked up after unpausing', async () => { + SequentialQueue.pause(); + for (let i = 0; i < 5; i++) { + SequentialQueue.push({command: `OpenReport${i}`}); + SequentialQueue.push({command: `AddComment${i}`}); + } + SequentialQueue.push(request); + SequentialQueue.push({command: 'AddComment6'}); + SequentialQueue.push({command: 'OpenReport6'}); + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + const requestWithConflictResolution: Request = { + command: 'ReconnectApp-replaced', + data: {accountID: 56789}, + checkAndFixConflictingRequest: (persistedRequests) => { + // should be one instance of ReconnectApp, get the index to replace it later + const index = persistedRequests.findIndex((r) => r.command === 'ReconnectApp'); + if (index === -1) { + return {conflictAction: {type: 'push'}}; + } + + return { + conflictAction: {type: 'replace', index}, + }; + }, + }; + + Promise.resolve().then(() => { + SequentialQueue.unpause(); + }); + Promise.resolve().then(() => { + SequentialQueue.push(requestWithConflictResolution); + }); + + await Promise.resolve(); + await waitForActiveRequestsToBeEmpty(); + const persistedRequests = PersistedRequests.getAll(); + + // We know ReconnectApp is at index 9 in the queue, so we can get it to verify + // that was replaced by the new request. + expect(persistedRequests.at(9)?.command).toBe('ReconnectApp-replaced'); + expect(persistedRequests.at(9)?.data?.accountID).toBe(56789); + }); + + // I need to test now when moving the request from the queue to the ongoing request the PERSISTED_REQUESTS is decreased and PERSISTED_ONGOING_REQUESTS has the new request + it('should move the request from the queue to the ongoing request and save it into Onyx', () => { + const persistedRequest = {...request, persistWhenOngoing: true}; + SequentialQueue.push(persistedRequest); + + const connectionId = Onyx.connect({ + key: ONYXKEYS.PERSISTED_ONGOING_REQUESTS, + callback: (ongoingRequest) => { + if (!ongoingRequest) { + return; + } + + Onyx.disconnect(connectionId); + expect(ongoingRequest).toEqual(persistedRequest); + expect(ongoingRequest).toEqual(PersistedRequests.getOngoingRequest()); + expect(PersistedRequests.getAll().length).toBe(0); + }, + }); + }); + + it('should get the ongoing request from onyx and start processing it', async () => { + const persistedRequest = {...request, persistWhenOngoing: true}; + Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, persistedRequest); + SequentialQueue.push({command: 'OpenReport'}); + + await Promise.resolve(); + + expect(persistedRequest).toEqual(PersistedRequests.getOngoingRequest()); + expect(PersistedRequests.getAll().length).toBe(1); + }); +});